diff --git a/.gitignore b/.gitignore index a0b1f35cd..69585f0ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,25 @@ +.gitignore +.github *.sublime-project *.sublime-workspace ._* -.gitignore .vscode/ +.vs/ +.DS_Store __*__ ignore/ -*.egg-info/ python/test/xstudio.log +python/src/xstudio/version.py +*.egg-info/ docs/Doxyfile docs/conf.py -python/src/xstudio/version.py -.vs/ -.DS_Store -/build/ -xstudio_install/ -**/qml/*_qml_export.h -CMakeUserPresets.json -__build -__build_debug +build/ +__build/ +__build_debug/ build_rel/ build_dbg/ +xstudio_install +CMakeUserPresets.json aqtinstall.log build_logs/ CMakeUserPresets.json.bak diff --git a/CMakeLists.txt b/CMakeLists.txt index 30ad20576..fb33cb4c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,9 @@ set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fpic") set(TEST_RESOURCE "${CMAKE_CURRENT_SOURCE_DIR}/test_resource") set(ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}) +# Generated headers (e.g. QML export headers) live in the build tree, not the source tree. +include_directories(${CMAKE_BINARY_DIR}/gen-include) + if(WIN32) set(CMAKE_CXX_STANDARD 20) add_compile_definitions($<$:_ITERATOR_DEBUG_LEVEL=0>) @@ -266,6 +269,10 @@ if(INSTALL_XSTUDIO) install(DIRECTORY include/xstudio DESTINATION include) + # QML export headers are generated into the build tree (see cmake/macros.cmake) + install(DIRECTORY ${CMAKE_BINARY_DIR}/gen-include/xstudio + DESTINATION include) + INSTALL(DIRECTORY extern/ DESTINATION extern) add_subdirectory(share/preference) @@ -308,6 +315,14 @@ if(INSTALL_XSTUDIO) EXPORT_LINK_INTERFACE_LIBRARIES ) + # Also export the targets into the build tree, next to the generated + # xStudioConfig.cmake, so out-of-tree plugins can consume the build + # directory directly (xStudio_DIR=) without installing. + export(EXPORT xstudio + FILE ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Targets.cmake + NAMESPACE xstudio:: + ) + endif() endif () diff --git a/cmake/macros.cmake b/cmake/macros.cmake index 7ef139d72..5fbbf18a3 100644 --- a/cmake/macros.cmake +++ b/cmake/macros.cmake @@ -94,6 +94,7 @@ macro(default_options_local name) target_include_directories(${name} PUBLIC $ + $ # $ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -136,6 +137,7 @@ macro(default_options name) target_include_directories(${name} INTERFACE $ + $ $ $ $ @@ -153,6 +155,7 @@ macro(default_options_static name) target_include_directories(${name} PUBLIC $ + $ # $ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -175,6 +178,7 @@ macro(default_plugin_options name) target_include_directories(${name} PUBLIC $ + $ # $ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -275,6 +279,7 @@ if (BUILD_TESTING) target_include_directories(${name} PUBLIC $ + $ # $ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -294,6 +299,7 @@ macro(default_options_qt name) target_include_directories(${name} PUBLIC $ + $ # $ PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src @@ -661,7 +667,7 @@ macro(create_qml_component_with_alias NAME ALIASNAME VERSION DEPS EXTRAMOC) # Generate export header include(GenerateExportHeader) - generate_export_header(${PROJECT_NAME} EXPORT_FILE_NAME "${ROOT_DIR}/include/xstudio/ui/qml/${PROJECT_NAME}_export.h") + generate_export_header(${PROJECT_NAME} EXPORT_FILE_NAME "${CMAKE_BINARY_DIR}/gen-include/xstudio/ui/qml/${PROJECT_NAME}_export.h") target_link_libraries(${PROJECT_NAME} PUBLIC ${DEPS} ) @@ -669,10 +675,6 @@ macro(create_qml_component_with_alias NAME ALIASNAME VERSION DEPS EXTRAMOC) set_target_properties(${PROJECT_NAME} PROPERTIES LINK_DEPENDS_NO_SHARED true) set_property(TARGET ${PROJECT_NAME} PROPERTY AUTOMOC ON) - ## Add the directory containing the generated export header to the include directories - #target_include_directories(${PROJECT_NAME} - # PUBLIC ${CMAKE_BINARY_DIR} # Include the build directory - #) endmacro() diff --git a/include/xstudio/atoms.hpp b/include/xstudio/atoms.hpp index b3aa32325..a4ae8956d 100644 --- a/include/xstudio/atoms.hpp +++ b/include/xstudio/atoms.hpp @@ -299,6 +299,7 @@ CAF_BEGIN_TYPE_ID_BLOCK(xstudio_simple_types, FIRST_CUSTOM_ID) CAF_ADD_TYPE_ID(xstudio_simple_types, (xstudio::utility::Notification)) CAF_ADD_TYPE_ID(xstudio_simple_types, (xstudio::playhead::SelectionMode)) CAF_ADD_TYPE_ID(xstudio_simple_types, (xstudio::timeline::ItemType)) + CAF_ADD_TYPE_ID(xstudio_simple_types, (xstudio::timeline::AudioMode)) CAF_ADD_TYPE_ID(xstudio_simple_types, (xstudio::utility::ColourTriplet)) CAF_ADD_TYPE_ID(xstudio_simple_types, (spdlog::level::level_enum)) @@ -711,6 +712,7 @@ CAF_BEGIN_TYPE_ID_BLOCK(xstudio_session_atoms, FIRST_CUSTOM_ID + (200 * 4)) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::timeline, split_item_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::timeline, split_item_at_frame_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::timeline, trimmed_range_atom) + CAF_ADD_ATOM(xstudio_session_atoms, xstudio::timeline, audio_mode_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::timeline, item_flag_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::media, metadata_selection_atom) @@ -727,7 +729,8 @@ CAF_BEGIN_TYPE_ID_BLOCK(xstudio_session_atoms, FIRST_CUSTOM_ID + (200 * 4)) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::timeline, item_selection_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::timeline, item_type_atom) - + CAF_ADD_ATOM(xstudio_session_atoms, xstudio::timeline, clip_edited_status_atom) + CAF_ADD_ATOM(xstudio_session_atoms, xstudio::bookmark, remove_annotation_atom) @@ -836,6 +839,7 @@ CAF_BEGIN_TYPE_ID_BLOCK(xstudio_playback_atoms, FIRST_CUSTOM_ID + (200 * 5)) CAF_ADD_ATOM(xstudio_playback_atoms, xstudio::colour_pipeline, global_ocio_controls_atom) CAF_ADD_ATOM(xstudio_playback_atoms, xstudio::colour_pipeline, colour_pipe_linearise_data_atom) CAF_ADD_ATOM(xstudio_playback_atoms, xstudio::colour_pipeline, colour_pipe_display_data_atom) + CAF_ADD_ATOM(xstudio_playback_atoms, xstudio::colour_pipeline, get_colourspace_info_atom) CAF_ADD_ATOM(xstudio_playback_atoms, xstudio::playhead, skip_to_bookmark_atom) CAF_ADD_ATOM(xstudio_playback_atoms, xstudio::playhead, skip_to_media_atom) @@ -962,6 +966,12 @@ template bool inspect(Inspector &f, ItemType &x) { auto setter = [&x](int_t val) { x = static_cast(val); }; return f.apply(getter, setter); } +template bool inspect(Inspector &f, AudioMode &x) { + using int_t = std::underlying_type_t; + auto getter = [&x] { return static_cast(x); }; + auto setter = [&x](int_t val) { x = static_cast(val); }; + return f.apply(getter, setter); +} } // namespace xstudio::timeline namespace xstudio::playhead { diff --git a/include/xstudio/media/media_actor.hpp b/include/xstudio/media/media_actor.hpp index 38b14631c..f166655b1 100644 --- a/include/xstudio/media/media_actor.hpp +++ b/include/xstudio/media/media_actor.hpp @@ -10,6 +10,7 @@ #include "xstudio/json_store/json_store_handler.hpp" #include "xstudio/utility/tree.hpp" #include "xstudio/utility/uuid.hpp" +#include "xstudio/utility/notification_handler.hpp" namespace xstudio::media { class MediaActor : public caf::event_based_actor { @@ -33,7 +34,9 @@ class MediaActor : public caf::event_based_actor { caf::message_handler message_handler(); caf::behavior make_behavior() override { - return message_handler().or_else(base_.container_message_handler(this)); + return message_handler().or_else(base_.container_message_handler(this)) + .or_else(notification_.message_handler(this, base_.event_group())) + .or_else(utility::NotificationHandler::default_event_handler()); } [[nodiscard]] const char *name() const override { return NAME.c_str(); } static caf::message_handler default_event_handler(); @@ -79,6 +82,8 @@ class MediaActor : public caf::event_based_actor { caf::actor dst_bookmarks); Media base_; + utility::NotificationHandler notification_; + caf::actor json_store_; std::map media_sources_; utility::UuidList bookmark_uuids_; @@ -87,6 +92,7 @@ class MediaActor : public caf::event_based_actor { utility::JsonStore human_readable_info_; utility::JsonStore media_list_columns_info_; utility::time_point creation_time_ = {utility::clock::now()}; + bool pending_media_display_info_{false}; }; class MediaSourceActor : public caf::event_based_actor { diff --git a/include/xstudio/playhead/playhead.hpp b/include/xstudio/playhead/playhead.hpp index e2815d85b..1d0eaa59d 100644 --- a/include/xstudio/playhead/playhead.hpp +++ b/include/xstudio/playhead/playhead.hpp @@ -197,6 +197,7 @@ class PlayheadBase : public module::Module { module::JsonAttribute *cached_frames_; module::IntegerVecAttribute *bookmarked_frames_; module::IntegerVecAttribute *media_transition_frames_; + module::JsonAttribute *compared_timeline_ids_; module::IntegerAttribute *max_compare_sources_; module::BooleanAttribute *restore_play_state_after_scrub_; diff --git a/include/xstudio/playhead/playhead_actor.hpp b/include/xstudio/playhead/playhead_actor.hpp index b3b83b5e3..f4e980a52 100644 --- a/include/xstudio/playhead/playhead_actor.hpp +++ b/include/xstudio/playhead/playhead_actor.hpp @@ -34,6 +34,8 @@ class PlayheadActor : public caf::event_based_actor, public PlayheadBase { [[nodiscard]] const char *name() const override { return NAME.c_str(); } + static inline const utility::Uuid TIMELINE_COMPARE_PLAYHEAD_UUID = utility::Uuid("442f6169-a3b0-465d-850d-03280b224d70"); + private: inline static const std::string NAME = "PlayheadActor"; diff --git a/include/xstudio/playhead/playhead_global_events_actor.hpp b/include/xstudio/playhead/playhead_global_events_actor.hpp index 3f97563f3..cc071e7c0 100644 --- a/include/xstudio/playhead/playhead_global_events_actor.hpp +++ b/include/xstudio/playhead/playhead_global_events_actor.hpp @@ -41,6 +41,7 @@ class PlayheadGlobalEventsActor : public caf::event_based_actor { caf::actor event_group_; // caf::actor fine_grain_events_group_; caf::actor global_active_playhead_; + caf::actor dummy_playhead_; struct ViewportAndPlayhead { caf::actor viewport; caf::actor playhead; diff --git a/include/xstudio/session/session_actor.hpp b/include/xstudio/session/session_actor.hpp index 47f28c208..259d954d7 100644 --- a/include/xstudio/session/session_actor.hpp +++ b/include/xstudio/session/session_actor.hpp @@ -110,6 +110,8 @@ class SessionActor : public caf::event_based_actor { } void check_media_hook_plugin_version(const utility::JsonStore &jsn, const caf::uri &path); + void check_multi_timeline_mode(); + void setOnScreenPlayhead(const bool check_if_current_session); utility::NotificationHandler notification_; @@ -126,6 +128,7 @@ class SessionActor : public caf::event_based_actor { // store gui conext information utility::UuidActor viewedContainer_; utility::UuidActor inspectedContainer_; + utility::UuidActor timeline_compare_playhead_; utility::UuidActorVector selection_; diff --git a/include/xstudio/shotgun_client/shotgun_client_actor.hpp b/include/xstudio/shotgun_client/shotgun_client_actor.hpp index 4a885ae21..a64dac6dc 100644 --- a/include/xstudio/shotgun_client/shotgun_client_actor.hpp +++ b/include/xstudio/shotgun_client/shotgun_client_actor.hpp @@ -11,14 +11,18 @@ namespace xstudio::shotgun_client { class ShotgunClientActor : public caf::event_based_actor { public: - ShotgunClientActor(caf::actor_config &cfg); + ShotgunClientActor( + caf::actor_config &cfg, + const time_t &connection_timeout = CPPHTTPLIB_CONNECTION_TIMEOUT_SECOND, + const time_t &read_timeout = 10, + const time_t &write_timeout = 10 + ); ~ShotgunClientActor() override = default; [[nodiscard]] const char *name() const override { return NAME.c_str(); } private: inline static const std::string NAME = "ShotgunClientActor"; - void init(); caf::behavior make_behavior() override { return behavior_; } template void authenticate(T rp, std::function lambda) { diff --git a/include/xstudio/timeline/clip.hpp b/include/xstudio/timeline/clip.hpp index 030076091..ca29c2a35 100644 --- a/include/xstudio/timeline/clip.hpp +++ b/include/xstudio/timeline/clip.hpp @@ -31,6 +31,8 @@ class Clip : public utility::Container { [[nodiscard]] bool valid_child(const Item &child) const { return item_.valid_child(child); } + [[nodiscard]] int clip_has_been_edited() const; + [[nodiscard]] const utility::Uuid &media_uuid() const { return media_uuid_; } utility::JsonStore set_media_uuid(const utility::Uuid &media_uuid) { auto jsn = item_.prop(); diff --git a/include/xstudio/timeline/clip_actor.hpp b/include/xstudio/timeline/clip_actor.hpp index 503b4ede1..3d529b4df 100644 --- a/include/xstudio/timeline/clip_actor.hpp +++ b/include/xstudio/timeline/clip_actor.hpp @@ -44,5 +44,6 @@ class ClipActor : public caf::event_based_actor { std::map> audio_ptr_cache_; std::map> image_ptr_cache_; + }; } // namespace xstudio::timeline diff --git a/include/xstudio/timeline/enums.hpp b/include/xstudio/timeline/enums.hpp index 177b5628f..2c0064a1a 100644 --- a/include/xstudio/timeline/enums.hpp +++ b/include/xstudio/timeline/enums.hpp @@ -2,6 +2,7 @@ #pragma once namespace xstudio::timeline { + typedef enum { IT_NONE = 0x0L, IT_GAP = 0x1L, @@ -11,4 +12,10 @@ typedef enum { IT_STACK = 0x5L, IT_TIMELINE = 0x6L } ItemType; + +typedef enum { + AM_USE_AUDIO_STACK = 0x0L, + AM_USE_VIDEO_STACK = 0x1L +} AudioMode; + } diff --git a/include/xstudio/timeline/item.hpp b/include/xstudio/timeline/item.hpp index d1d869714..362e25e1d 100644 --- a/include/xstudio/timeline/item.hpp +++ b/include/xstudio/timeline/item.hpp @@ -302,7 +302,8 @@ class Item : private Items { const utility::FrameRate timeline_frame_rate, const media::MediaType mt, const utility::UuidSet &focus, - bool only_if_focussed) const; + bool only_if_focussed, + const AudioMode audio_mode) const; [[nodiscard]] std::optional resolve_time( const utility::FrameRate &time, @@ -336,6 +337,7 @@ class Item : private Items { const media::MediaType media_type, const utility::TimeSourceMode tsm, const utility::FrameRate &override_rate, + const timeline::AudioMode audio_mode = AM_USE_AUDIO_STACK, const utility::UuidSet &focus_list = utility::UuidSet()); private: diff --git a/include/xstudio/timeline/timeline.hpp b/include/xstudio/timeline/timeline.hpp index e48e1fd8e..045ab46a0 100644 --- a/include/xstudio/timeline/timeline.hpp +++ b/include/xstudio/timeline/timeline.hpp @@ -56,6 +56,9 @@ class Timeline : public utility::Container { [[nodiscard]] bool empty() const { return media_list_.empty(); } void clear() { media_list_.clear(); } + [[nodiscard]] AudioMode audio_mode() const { return audio_mode_; } + void set_audio_mode(const AudioMode am) { audio_mode_ = am; } + [[nodiscard]] utility::UuidList media() const { return media_list_.uuids(); } [[nodiscard]] utility::UuidVector media_vector() const { return media_list_.uuid_vector(); } void insert_media( @@ -107,9 +110,11 @@ class Timeline : public utility::Container { // } private: + Item item_; utility::UuidListContainer media_list_; utility::UuidSet focus_list_; + AudioMode audio_mode_; // utility::UuidListContainer tracks_; // utility::FrameRateDuration start_time_; diff --git a/include/xstudio/timeline/timeline_actor.hpp b/include/xstudio/timeline/timeline_actor.hpp index b7f701de2..16481aeb2 100644 --- a/include/xstudio/timeline/timeline_actor.hpp +++ b/include/xstudio/timeline/timeline_actor.hpp @@ -103,6 +103,12 @@ class TimelineActor : public caf::event_based_actor { void duplicate_playhead(caf::actor duplicated_timeline); + void serialise_selection_for_clipboard( + caf::typed_response_promise rp, const utility::UuidVector &selected_items); + + void paste_tracks_from_serialisation( + caf::typed_response_promise rp, const std::string &track_serialisation); + Timeline base_; caf::actor change_event_group_; diff --git a/include/xstudio/timeline/track_actor.hpp b/include/xstudio/timeline/track_actor.hpp index 60435cdae..d1818f049 100644 --- a/include/xstudio/timeline/track_actor.hpp +++ b/include/xstudio/timeline/track_actor.hpp @@ -111,6 +111,11 @@ class TrackActor : public caf::event_based_actor { const bool add_gap, const bool replace_with_gap = false); + void do_partial_duplication( + caf::typed_response_promise rp, + const utility::UuidVector &selection_for_duplication + ); + void merge_gaps(caf::typed_response_promise rp); utility::JsonStore merge_gaps(); diff --git a/include/xstudio/ui/canvas/stroke.hpp b/include/xstudio/ui/canvas/stroke.hpp index 11962113f..44e18c058 100644 --- a/include/xstudio/ui/canvas/stroke.hpp +++ b/include/xstudio/ui/canvas/stroke.hpp @@ -104,6 +104,7 @@ struct Stroke { bool fade(const float fade_amount); [[nodiscard]] float opacity() const { return _opacity; } + [[nodiscard]] float fade_factor() const { return _fade_factor; } [[nodiscard]] float thickness() const { return _thickness; } [[nodiscard]] float softness() const { return _softness; } [[nodiscard]] float size_sensitivity() const { return _size_sensitivity; } @@ -123,6 +124,7 @@ struct Stroke { size_t _hash{0}; float _opacity{1.0f}; + float _fade_factor{1.0f}; float _thickness{0.0f}; float _softness{0.0f}; float _size_sensitivity{0.0f}; diff --git a/include/xstudio/ui/model_data/model_data_actor.hpp b/include/xstudio/ui/model_data/model_data_actor.hpp index 4149a07ac..6fe68eb95 100644 --- a/include/xstudio/ui/model_data/model_data_actor.hpp +++ b/include/xstudio/ui/model_data/model_data_actor.hpp @@ -137,6 +137,12 @@ class GlobalUIModelData : public caf::event_based_actor { const utility::JsonStore &data, caf::actor excluded_client); + void register_watcher_with_model(caf::actor watcher, const std::string &model_name); + + void register_client_with_model(caf::actor watcher, const std::string &model_name); + + void do_cleanup_for_exited_clients(); + struct ModelData { ModelData() = default; ModelData(const ModelData &o) = default; @@ -148,14 +154,14 @@ class GlobalUIModelData : public caf::event_based_actor { : name_(name), data_(utility::json_to_tree(data, "children")), preference_path_(std::move(preference_path)) { - clients_.push_back(client); + clients_.insert(caf::actor_cast(client)); } std::string name_; std::string sort_key_; utility::JsonTree data_; std::string preference_path_; - std::vector clients_; - std::map> menu_watchers_; + std::set clients_; + std::map> menu_watchers_; bool pending_prefs_update_ = {false}; std::set hotkeys_; }; @@ -170,6 +176,11 @@ class GlobalUIModelData : public caf::event_based_actor { std::map models_; std::set models_to_be_fully_broadcasted_; + std::set exiting_clients_and_watchers_; + std::map> models_per_watcher_; + std::map> models_per_client_; + + utility::clock::time_point cleanup_timepoint_; caf::behavior behavior_; }; diff --git a/include/xstudio/ui/qml/embedded_python_ui.hpp b/include/xstudio/ui/qml/embedded_python_ui.hpp index eaafb260b..1c2c8f1af 100644 --- a/include/xstudio/ui/qml/embedded_python_ui.hpp +++ b/include/xstudio/ui/qml/embedded_python_ui.hpp @@ -84,6 +84,7 @@ class EMBEDDED_PYTHON_QML_EXPORT EmbeddedPythonUI using super = caf::mixin::actor_object; enum Roles { nameRole = JSONTreeModel::Roles::LASTROLE, + menuHotKeyRole, menuPathRole, scriptPathRole, snippetTypeRole, diff --git a/include/xstudio/ui/qml/helper_ui.hpp b/include/xstudio/ui/qml/helper_ui.hpp index 46954cd67..3b7b31a2c 100644 --- a/include/xstudio/ui/qml/helper_ui.hpp +++ b/include/xstudio/ui/qml/helper_ui.hpp @@ -494,6 +494,22 @@ class HELPER_QML_EXPORT Helpers : public QObject { Helpers(QQmlEngine *engine, QObject *parent = nullptr) : QObject(parent), engine_(engine) {} ~Helpers() override = default; + // Logging from QML into the xstudio log (terminal, --log-file and the Log + // panel). console.* only reaches stderr; use these when the message must + // be in the log at a chosen level, e.g. helpers.logWarning("..."). + Q_INVOKABLE void logDebug(const QString &msg) const { + spdlog::debug("QML: {}", StdFromQString(msg)); + } + Q_INVOKABLE void logInfo(const QString &msg) const { + spdlog::info("QML: {}", StdFromQString(msg)); + } + Q_INVOKABLE void logWarning(const QString &msg) const { + spdlog::warn("QML: {}", StdFromQString(msg)); + } + Q_INVOKABLE void logError(const QString &msg) const { + spdlog::error("QML: {}", StdFromQString(msg)); + } + Q_INVOKABLE [[nodiscard]] bool openURL(const QUrl &url) const { return openURLFuture(url).result(); } @@ -846,7 +862,7 @@ class HELPER_QML_EXPORT Helpers : public QObject { Q_INVOKABLE void moduleCallback(const QString &module_actor, const QVariant cb_data); Q_INVOKABLE QFuture - pythonAsyncCallback(const QString pluginName, const QString method, QVariant args); + pythonAsyncCallback(const QString pluginName, const QString method, QVariant args = QVariant()) const; Q_INVOKABLE QVariant pluginCallback(const QUuid &plugin_uuid, const QVariant cb_data); diff --git a/include/xstudio/ui/qml/json_tree_model_ui.hpp b/include/xstudio/ui/qml/json_tree_model_ui.hpp index 402fa3edf..d7ac16326 100644 --- a/include/xstudio/ui/qml/json_tree_model_ui.hpp +++ b/include/xstudio/ui/qml/json_tree_model_ui.hpp @@ -15,7 +15,7 @@ CAF_POP_WARNINGS #include "xstudio/utility/uuid.hpp" #include "xstudio/ui/qml/actor_object.hpp" -#include "helper_qml_export.h" +#include "xstudio/ui/qml/helper_qml_export.h" namespace xstudio::ui::qml { diff --git a/include/xstudio/ui/qml/model_data_ui.hpp b/include/xstudio/ui/qml/model_data_ui.hpp index c378b5637..037379767 100644 --- a/include/xstudio/ui/qml/model_data_ui.hpp +++ b/include/xstudio/ui/qml/model_data_ui.hpp @@ -204,6 +204,9 @@ class HELPER_QML_EXPORT MenuModelItem : public caf::mixin::actor_object virtual void init(caf::actor_system &system); + Q_PROPERTY( + QObject *panelContext READ panelContext WRITE setPanelContext NOTIFY + panelContextChanged) Q_PROPERTY(QString menuPath READ menuPath WRITE setMenuPath NOTIFY menuPathChanged) Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged) Q_PROPERTY( @@ -227,9 +230,6 @@ class HELPER_QML_EXPORT MenuModelItem : public caf::mixin::actor_object customMenuQmlChanged) Q_PROPERTY(QVariant userData READ userData WRITE setUserData NOTIFY userDataChanged) Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged) - Q_PROPERTY( - QObject *panelContext READ panelContext WRITE setPanelContext NOTIFY - panelContextChanged) Q_PROPERTY( QString menuToolTip READ menuToolTip WRITE setMenuToolTip NOTIFY menuToolTipChanged) diff --git a/include/xstudio/ui/qml/session_model_ui.hpp b/include/xstudio/ui/qml/session_model_ui.hpp index b5f1b7de4..313a06867 100644 --- a/include/xstudio/ui/qml/session_model_ui.hpp +++ b/include/xstudio/ui/qml/session_model_ui.hpp @@ -53,6 +53,12 @@ class SESSION_QML_EXPORT SessionModel : public caf::mixin::actor_object importTimelineFuture( @@ -200,15 +208,12 @@ class SESSION_QML_EXPORT SessionModel : public caf::mixin::actor_object exportOTIO( const QModelIndex &timeline, const QUrl &path, @@ -493,6 +500,14 @@ class SESSION_QML_EXPORT SessionModel : public caf::mixin::actor_objecthome_system(); } @@ -665,6 +682,8 @@ class SESSION_QML_EXPORT SessionModel : public caf::mixin::actor_object media_thumbnails_; // key is actor string utility::UuidSet processed_events_; diff --git a/include/xstudio/utility/helpers.hpp b/include/xstudio/utility/helpers.hpp index 5b987bad1..b451d965e 100644 --- a/include/xstudio/utility/helpers.hpp +++ b/include/xstudio/utility/helpers.hpp @@ -84,6 +84,8 @@ template class AutoResponderBase { } } + void increment(int ct) { count_ += ct; } + void decrement(int ct) { count_ -= ct; if (count_ <= 0 && rp_.pending()) { @@ -162,6 +164,8 @@ template class AutoResponder : private std::shared_ptrget()->result(); } + void increment(int ct = 1) { this->get()->increment(ct); } + void decrement(int ct = 1) { this->get()->decrement(ct); } void decrement(const caf::error &err, int ct = 1) { this->get()->decrement(err, ct); } @@ -655,6 +659,7 @@ std::string forward_remap_file_path(const std::string &path); std::string reverse_remap_file_path(const std::string &path); void add_remap_file_path(const std::string &from, const std::string &to); +void add_regex_mapping(const std::pair from, const std::pair to); // The json store here must be an array. Each element in the array must be // another array of 2 strings and a boolean. diff --git a/include/xstudio/utility/path_remapper.hpp b/include/xstudio/utility/path_remapper.hpp index 9689408ba..4049bbd8f 100644 --- a/include/xstudio/utility/path_remapper.hpp +++ b/include/xstudio/utility/path_remapper.hpp @@ -23,6 +23,7 @@ class PathRemapper { void configure(const utility::JsonStore &jsn); void add_path_mapping(const std::string &from, const std::string &to); + void add_regex_mapping(const std::pair from, const std::pair to); private: std::string remap(const std::string &path, const bool forwards); diff --git a/python/src/xstudio/api/module.py b/python/src/xstudio/api/module.py index 01d1f2c4e..b2ea70f06 100644 --- a/python/src/xstudio/api/module.py +++ b/python/src/xstudio/api/module.py @@ -66,6 +66,7 @@ def __init__( converted_val, JsonStore(attribute_role_data) )[0] + self.__name = None def role_data(self, role_name): @@ -132,14 +133,16 @@ def value(self): @property def name(self): - return self.connection.request_receive( - self.parent_remote, - attribute_value_atom(), - self.uuid, - int(AttributeRole.Title) - )[0].get() + if not self.__name: + self.__name = self.connection.request_receive( + self.parent_remote, + attribute_value_atom(), + self.uuid, + int(AttributeRole.Title) + )[0].get() + return self.__name - def set_value(self, value): + def set_value(self, value, notify=None): if isinstance(value, list): pass @@ -151,11 +154,19 @@ def set_value(self, value): elif not isinstance(value, JsonStore): value = JsonStore(value) - self.connection.send( - self.parent_remote, - attribute_value_atom(), - self.uuid, - value) + if notify == None: + self.connection.send( + self.parent_remote, + attribute_value_atom(), + self.uuid, + value) + else: + self.connection.send( + self.parent_remote, + attribute_value_atom(), + self.uuid, + value, + notify) def add_to_preferences(self): diff --git a/python/src/xstudio/api/session/playlist/timeline/__init__.py b/python/src/xstudio/api/session/playlist/timeline/__init__.py index c688330fd..fe9751c51 100644 --- a/python/src/xstudio/api/session/playlist/timeline/__init__.py +++ b/python/src/xstudio/api/session/playlist/timeline/__init__.py @@ -4,6 +4,7 @@ from xstudio.core import URI, selection_actor_atom, item_selection_atom, item_type_atom, get_media_atom, save_atom, export_atom from xstudio.core import get_playlist_atom from xstudio.core import import_atom, erase_item_atom, get_playhead_atom, FrameRate, FrameRateDuration +from xstudio.core import AudioMode, audio_mode_atom from xstudio.api.session.container import Container from xstudio.api.intrinsic import History from xstudio.api.session.media.media import Media @@ -438,6 +439,17 @@ def media(self): result = self.connection.request_receive(self.remote, get_media_atom())[0] return [Media(self.connection, i.actor, i.uuid) for i in result] + @property + def visible_media(self): + """Get only media that is visible when playing through the timeline. + This is the media that is in the video stack and not hidden by a track + or stack. + + Returns: + media(list[media]): Media + """ + result = self.connection.request_receive(self.remote, get_media_atom(), True, True)[0] + return [Media(self.connection, i.actor, i.uuid) for i in result] @property def playhead(self): @@ -473,6 +485,26 @@ def parent_playlist(self): result = self.connection.request_receive(self.remote, get_playlist_atom())[0] return Playlist(self.connection, result) + @property + def audio_mode(self): + """Get the audio mode of the timeline. The audio mode determines if + audio is played from the media in the video stack or the audio stack. + + Returns: + source(AudioMode): Currently timeline audio mode. + """ + result = self.connection.request_receive(self.remote, audio_mode_atom())[0] + return result + + @audio_mode.setter + def audio_mode(self, amode): + """Set the current, audio mode of the timeline. The audio mode determines if + audio is played from the media in the video stack or the audio stack. + + Args: + AudioMode: AudioMode.AM_USE_AUDIO_STACK or AudioMode.AM_USE_VIDEO_STACK + """ + self.connection.send(self.remote, audio_mode_atom(), amode) # @property # def audio_tracks(self): diff --git a/python/src/xstudio/api/session/session.py b/python/src/xstudio/api/session/session.py index 08eb33b91..86ecf8a88 100644 --- a/python/src/xstudio/api/session/session.py +++ b/python/src/xstudio/api/session/session.py @@ -16,8 +16,9 @@ from xstudio.api.session.playlist.contact_sheet import ContactSheet from xstudio.api.session.playlist.timeline import Timeline from xstudio.api.auxiliary import NotificationHandler +from xstudio.api.auxiliary.json_store import JsonStoreHandler -class Session(Container, NotificationHandler): +class Session(Container, NotificationHandler, JsonStoreHandler): """Session object.""" def __init__(self, connection, remote, uuid=None): @@ -143,6 +144,9 @@ def inspected_container(self): """ result = self.connection.request_receive(self.remote, active_media_container_atom())[0] + if not result.actor: + return None + c = Container(self.connection, result.actor) if c.type == "Timeline": @@ -533,19 +537,20 @@ def render_container( frame_for_frame=False, video_codec_opts="-c:v libx264 -pix_fmt yuv420p -preset medium -tune film", audio_codec_opts="-c:a aac -ac 2 -ar 44100", + intermediate_bit_depth=8, auto_check_output=False, ocio_display="sRGB", ocio_view="Raw", timecode="", status_callback=None ): - """Render a playlist, timeline, subset or contact sheet to a quicktime + """Render a playlist, timeline, subset or contact sheet to a quicktime or image sequence (jpg, tiff, png etc) Args: container(Playlist/Timeline/Subset/ContactSheet): The item to render - output_file_path(str): Filesystem path to the output video file. For - frame based image sequences use # characters for frame number and - padding to zeros. E.g. /Users/ted/Video/my_render.####.jpg for 4 + output_file_path(str): Filesystem path to the output video file. For + frame based image sequences use # characters for frame number and + padding to zeros. E.g. /Users/ted/Video/my_render.####.jpg for 4 digit padded frame numbers output_audio_path(str): Filesystem path to the output audio file. This option ONLY applies if you are outputting frame based image sequences. @@ -562,7 +567,7 @@ def render_container( frame_for_frame(bool): If the media or timeline frame rate is different to the output frame rate, frames will map 1:1 to the output if this is True. If False, the output will match the input in - terms of duration. For example if rendering out at 12fps and the + terms of duration. For example if rendering out at 12fps and the media is 24 fps then using True for this flag will result in an output that is twice as long and half the speed of the input. Otherwise the output will be the same duration as the input and every @@ -572,18 +577,22 @@ def render_container( H264 encoding. audio_codec_opts(str): FFMpeg audio codec options. For example use "--c:a aac -ac 2 -ar 44100" for AAC 44.1kHz encoding. + intermediate_bit_depth(int): Bit depth to use for the image buffers + that are passed to ffmpeg for the encoding stage. Options are + 8 or 16. 16 bit is more computationally expensive but is + required if your output media has a higher bit depth than 8. auto_check_output(bool): Use True to immediately launch a quickview window with the render result when it has completed. ocio_display(str): The OCIO display profile to apply when generating the output ocio_view(str): The OCIO view to apply when generating the output - timecode(str/int): For containerised media a timecode string like + timecode(str/int): For containerised media a timecode string like "00:00:41:17" will inject timecode data into the output file. For frame based media a frame number here will set the start frame numbering for the output image file sequence. - status_callback(function(Uuid, dict)): An optional callback function. This + status_callback(function(Uuid, dict)): An optional callback function. This callback will be executed when the status of the render job has - changed. The status is provided as a dictionary including a + changed. The status is provided as a dictionary including a 'status' field which is set to 'Complete' when the job is finished successfully. @@ -620,8 +629,9 @@ def render_container( in_frame, out_frame, frame_rate if frame_rate != None else container.media[0].media_source().rate, + frame_for_frame, video_codec_opts, - "8", + "16" if intermediate_bit_depth == 16 else "8", audio_codec_opts, "Via Python", ocio_display, @@ -645,6 +655,19 @@ def render_container( return job_id + def cancel_render_job(self, job_id): + """Cancel a render job + + Args: + job_id(Uuid): The uuid of the render job to cancel + """ + vid_renderer = self.connection.api.plugin_manager.get_plugin_instance( + Uuid("4147f82d-1006-4025-ac04-79c81cd5e7b7") + ) + if not isinstance(job_id, Uuid): + job_id = Uuid(job_id) + self.connection.send(vid_renderer, render_to_video_atom(), job_id, True) + def __vid_renderer_events(self, event_message_content): if len(event_message_content) == 4 and isinstance(event_message_content[0], event_atom): diff --git a/python/src/xstudio/connection/__init__.py b/python/src/xstudio/connection/__init__.py index d3d269b3e..65272f35f 100644 --- a/python/src/xstudio/connection/__init__.py +++ b/python/src/xstudio/connection/__init__.py @@ -37,7 +37,7 @@ def __init__(self, auto_connect=False, background_processing=False, debug=False, self.app_type = None self.app_version = None self._api = None - self.default_timeout_ms = 100000 + self.default_timeout_ms = 10000 self.debug = debug self.responses = {} @@ -176,9 +176,11 @@ def process_events(self, timeout=10, forever=False): forever (bool): Don't return until API shutsdown. """ self.keep_processing = True - while self.connected and self.keep_processing: - self.dequeue_messages(timeout) - if not forever: + if forever: + self.link.wait_for_xstudio_exit() + else: + while self.connected and self.keep_processing: + self.dequeue_messages(timeout) break def add_broadcast_handler(self, broadcast, sender, handler): @@ -387,46 +389,7 @@ def request_receive(self, *args): def request_receive_timeout(self, timeout_milli, *args): """Send message and return response.""" - req_id = self.request(*args) - return self.response(req_id, timeout_milli) - - def request(self, *args): - """Send message and return promise.""" - req_id = self.link.request(*args) - if req_id: - self.responses[req_id] = None - - return req_id - - def response(self, req_id, timeout_milli=None): - """""" - if timeout_milli is not None: - try: - self._dequeue_messages(timeout_milli, req_id) - except TimeoutError: - # _dequeue_messages files every response it dequeues into - # self.responses, whatever request it was watching for - only - # its break is specific to watch_for. So another consumer of - # this connection's queue may have taken our response and - # stored it correctly while we were still waiting. Not having - # dequeued it ourselves is not the same as no answer arriving, - # and callers treat a timeout as evidence an actor is - # unresponsive. - if self.responses.get(req_id) is None: - raise - - return self._response(req_id) - - def _response(self, req_id): - """""" - result = None - if req_id in self.responses and self.responses[req_id] is not None: - result = self.responses[req_id] - del self.responses[req_id] - if isinstance(result[0], error): - raise RuntimeError(str(result[0])) - - return result + return self.link.request(timeout_milli, *args) def spawn(self, *args): """Spawn remote actor @@ -481,11 +444,14 @@ def _dequeue_messages(self, timeout_milli, watch_for = None): """ start = time.perf_counter() + if watch_for and watch_for in self.responses and self.responses[watch_for] is not None: + return + while True: try: msg = self.link.dequeue_message_with_timeout( - absolute_receive_timeout(int(timeout_milli)) + absolute_receive_timeout(int(5000)) ) except Exception as e: if str(e) == "Dequeue timeout": @@ -647,11 +613,6 @@ def get_plugin_instance(self, plugin_uuid): return self.plugins[pn] raise Exception("Plugin with uuid {} was not found".format(plugin_uuid)) - def hud_plugins_onscreen_data2(self, media_items): - for pn in self.plugins: - print (pn) - return JsonStore() - def hud_plugins_onscreen_data(self, media_items): """This method is called by the xstudio offscreen viewport(s) when they need to render overlay data accurately (e.g. when doing a snapshot or diff --git a/share/preference/core_conform.json b/share/preference/core_conform.json index 925d9b98f..650640631 100644 --- a/share/preference/core_conform.json +++ b/share/preference/core_conform.json @@ -20,7 +20,7 @@ "maximum": 10, "datatype": "int", "context": ["APPLICATION"], - "category": "Conform", + "category": "Timeline", "display_name": "Conform Neighbours Count" } } diff --git a/share/preference/core_sequence.json b/share/preference/core_sequence.json index 6d1786a17..f3f74a9c9 100644 --- a/share/preference/core_sequence.json +++ b/share/preference/core_sequence.json @@ -22,9 +22,29 @@ ], "datatype": "json", "context": ["APPLICATION"], - "category": "Conform", + "category": "Timeline", "options": "import xStudio 1.0; XsJsonPreference {}", "display_name": "Track Templates" + }, + "show_clip_handles": { + "path": "/core/sequence/show_clip_handles", + "default_value": true, + "description": "Toggle whether in/out handle frames are indicated on clips in the Timeline interface", + "value": true, + "datatype": "bool", + "context": ["APPLICATION"], + "category": "Timeline", + "display_name": "Show Handles on Timeline Clips" + }, + "show_edit_indicator": { + "path": "/core/sequence/show_edit_indicator", + "default_value": true, + "description": "Toggle whether the edit indicator is visible on timeline clips. The edit indicator is a small blue dot that shows in the bottom right corner of a clip if it's cut range (in/out frames) have been modified since the clip was first added to the timeline.", + "value": true, + "datatype": "bool", + "context": ["APPLICATION"], + "category": "Timeline", + "display_name": "Show Edit Indicator on Timeline Clips" } } } diff --git a/share/snippets/DNeg/export_flagged_clip_shots.py b/share/snippets/DNeg/export_flagged_clip_shots.py index 72f622514..bcc4deb9a 100644 --- a/share/snippets/DNeg/export_flagged_clip_shots.py +++ b/share/snippets/DNeg/export_flagged_clip_shots.py @@ -35,11 +35,34 @@ def export_flagged_clip_shots(item=XSTUDIO.api.session.viewed_container): if i[0] not in result: result[i[0]] = set() + shot = None + try: - result[i[0]].add(i[1].media.metadata["metadata"]["shotgun"]["shot"]["attributes"]["code"]) + shot = i[1].item_prop["DNEG_shot"] except: pass + if shot is None: + try: + shot = i[1].item_prop["dneg"]["shot"] + except: + pass + + if shot is None: + try: + shot = i[1].item_prop["metadata"]["external"]["DNeg"]["shot"] + except: + pass + + if shot is None: + try: + shot = i[1].media.metadata["metadata"]["shotgun"]["shot"]["attributes"]["code"] + except: + pass + + if shot is not None: + result[i[0]].add(shot) + for i in sorted(result.keys()): print(i+":") if(len(result[i])): diff --git a/share/snippets/clip/DNeg/trim_to_cut_range.py b/share/snippets/clip/DNeg/trim_to_cut_range.py index 874930a52..7aa792f13 100644 --- a/share/snippets/clip/DNeg/trim_to_cut_range.py +++ b/share/snippets/clip/DNeg/trim_to_cut_range.py @@ -39,7 +39,7 @@ def use_clip_range(item=XSTUDIO.api.session.viewed_container): cfr ), FrameRateDuration( - end-start, + (end-start)+1, cfr ) ) diff --git a/src/launch/xstudio/src/run_xstudio.bat.in b/src/launch/xstudio/src/run_xstudio.bat.in index 880cccdab..227e0bf44 100644 --- a/src/launch/xstudio/src/run_xstudio.bat.in +++ b/src/launch/xstudio/src/run_xstudio.bat.in @@ -5,7 +5,13 @@ REM an install or windeployqt run. Generated by CMake; do not edit. set "PATH=@_qt_bin_dir@;%~dp0vcpkg_installed\x64-windows\bin;%~dp0bin;%PATH%" set "XSTUDIO_ROOT=%~dp0bin" -set "XSTUDIO_PLUGIN_PATH=%~dp0share\xstudio\plugin" +REM Prepend rather than overwrite, so callers (e.g. the plugins repo's +REM generated xstudio-run.bat) can add their own plugin folders. +if defined XSTUDIO_PLUGIN_PATH ( + set "XSTUDIO_PLUGIN_PATH=%~dp0share\xstudio\plugin;%XSTUDIO_PLUGIN_PATH%" +) else ( + set "XSTUDIO_PLUGIN_PATH=%~dp0share\xstudio\plugin" +) set "XSTUDIO_PYTHONHOME=%~dp0vcpkg_installed\x64-windows\tools\python3" set "PYTHONPATH=%~dp0bin\python\lib\python3.11\site-packages;%PYTHONPATH%" "%~dp0bin\xstudio.exe" %* diff --git a/src/launch/xstudio/src/xstudio.cpp b/src/launch/xstudio/src/xstudio.cpp index 7431efae3..241c09543 100644 --- a/src/launch/xstudio/src/xstudio.cpp +++ b/src/launch/xstudio/src/xstudio.cpp @@ -165,10 +165,30 @@ void xstudioQtMessageHandler( QByteArray localMsg = msg.toLocal8Bit(); const char *file = context.file ? context.file : ""; const char *function = context.function ? context.function : ""; - - if (!strcmp("qml", context.category)) { - // qml messages are type = QtDebugMsg but we always want to see these. - spdlog::info("QML: {} ({}:{}, {})", localMsg.constData(), file, context.line, function); + const char *category = context.category ? context.category : ""; + + if (!strcmp("qml", category)) { + // console.log() arrives as QtDebugMsg but we always want to see it, so + // it maps to info. Warnings and errors from QML keep their severity so + // console.warn()/console.error() land in the log at the matching level. + switch (type) { + case QtWarningMsg: + spdlog::warn( + "QML: {} ({}:{}, {})", localMsg.constData(), file, context.line, function); + break; + case QtCriticalMsg: + spdlog::error( + "QML: {} ({}:{}, {})", localMsg.constData(), file, context.line, function); + break; + case QtFatalMsg: + spdlog::critical( + "QML: {} ({}:{}, {})", localMsg.constData(), file, context.line, function); + break; + default: + spdlog::info( + "QML: {} ({}:{}, {})", localMsg.constData(), file, context.line, function); + break; + } return; } diff --git a/src/media/src/media_actor.cpp b/src/media/src/media_actor.cpp index 44da9a93f..7a92bd71e 100644 --- a/src/media/src/media_actor.cpp +++ b/src/media/src/media_actor.cpp @@ -142,7 +142,9 @@ caf::message_handler MediaActor::default_event_handler() { media_reader::get_thumbnail_atom, const thumbnail::ThumbnailBufferPtr &) {}, [=](utility::event_atom, media::media_display_info_atom, const utility::JsonStore &) { - }}; + }, + [=](utility::event_atom, notification_atom, const JsonStore &) {} + }; } caf::message_handler MediaActor::message_handler() { @@ -1507,6 +1509,17 @@ caf::message_handler MediaActor::message_handler() { update_human_readable_details(rp); return rp; }, + + [=](media_display_info_atom, const bool) { + pending_media_display_info_ = false; + + mail( + utility::event_atom_v, + media_display_info_atom_v, + media_list_columns_info_) + .send(base_.event_group()); + }, + [=](media_display_info_atom) -> result { auto rp = make_response_promise(); mail(human_readable_info_atom_v, true) @@ -1891,9 +1904,9 @@ void MediaActor::update_human_readable_details( // The uri for frame based formats is a bit ugly ... here replace the frame // expr with some hashes - static std::regex re(R"(\.\{\:[0-9]*d\}\.)"); + static std::regex re(R"(\{\:[0-9]*d\}\.)"); const auto filepath = - std::regex_replace(uri_to_posix_path(ref.uri()), re, ".#."); + std::regex_replace(uri_to_posix_path(ref.uri()), re, "#."); r["File Path - MediaSource (Image)"] = filepath; r["File Name - MediaSource (Image)"] = fs::path(filepath).filename().string(); @@ -2111,12 +2124,15 @@ void MediaActor::build_media_list_info(caf::typed_response_promise std::chrono::seconds(5)) { + if (not pending_media_display_info_ and (utility::clock::now() - creation_time_) > std::chrono::seconds(5)) { mail( utility::event_atom_v, media_display_info_atom_v, media_list_columns_info_) .send(base_.event_group()); + } else if(not pending_media_display_info_) { + pending_media_display_info_ = true; + anon_mail(media_display_info_atom_v, true).delay(5s).send(this); } } rp.deliver(*result); diff --git a/src/module/src/module.cpp b/src/module/src/module.cpp index ba3ad42e6..3e1a4b72f 100644 --- a/src/module/src/module.cpp +++ b/src/module/src/module.cpp @@ -616,6 +616,39 @@ caf::message_handler Module::message_handler() { return caf::make_error(xstudio_error::error, err); }, + [=](attribute_value_atom, + const utility::Uuid &attr_uuid, + const utility::JsonStore &value, + const bool notify) -> result { + + try { + + for (const auto &p : attributes_) { + if (p->uuid() == attr_uuid) { + + if (value.is_null() && + p->role_data_as_json(Attribute::Value).is_null()) { + // attribute role data class currently triggers its 'notify' + // mechanism when setting null to null. Needs a fix. + return false; + } + p->set_role_data(Attribute::Value, value, notify); + return true; + } + } + + const std::string err = std::string("Request for set attribute on module \"") + + name_ + + std::string("\" using unknown attribute uuid."); + throw std::runtime_error(err.c_str()); + + } catch (std::exception &e) { + + std::cerr << e.what() << "\n"; + return caf::make_error(xstudio_error::error, e.what()); + } + }, + [=](attribute_value_atom, const utility::Uuid &attr_uuid, const utility::JsonStore &value) -> result { diff --git a/src/playhead/src/playhead.cpp b/src/playhead/src/playhead.cpp index 21d9123e8..eeec9baf0 100644 --- a/src/playhead/src/playhead.cpp +++ b/src/playhead/src/playhead.cpp @@ -93,6 +93,7 @@ void PlayheadBase::add_attributes() { duration_frames_ = add_integer_attribute("Duration Frames", "Duration Frames", 0); duration_seconds_ = add_float_attribute("Duration Seconds", "Duration Seconds", 0.0); cached_frames_ = add_json_attribute("Cached Frames"); + compared_timeline_ids_ = add_json_attribute("Compared Timeline Ids", "Compared Timeline Ids", nlohmann::json::array()); bookmarked_frames_ = add_int_vec_attribute("Bookmarked Frames"); playhead_volume_ = add_float_attribute("Volume", "Volume", 100.0); diff --git a/src/playhead/src/playhead_actor.cpp b/src/playhead/src/playhead_actor.cpp index f0c2e1072..9eaf7b116 100644 --- a/src/playhead/src/playhead_actor.cpp +++ b/src/playhead/src/playhead_actor.cpp @@ -142,9 +142,11 @@ PlayheadActor::PlayheadActor( init(); set_parent_actor_addr(actor_cast(this)); - playlist_selection_addr_ = caf::actor_cast(playlist_selection); - anon_mail(playlist::selection_actor_atom_v, playlist_selection) - .send(actor_cast(this)); + if (playlist_selection) { + playlist_selection_addr_ = caf::actor_cast(playlist_selection); + anon_mail(playlist::selection_actor_atom_v, playlist_selection) + .send(actor_cast(this)); + } // for every attribute we expose it in frontend model data, where the id // of the model data set is the uuid of the module here. This means if we have @@ -351,7 +353,12 @@ void PlayheadActor::init() { return mail(jump_atom_v, (int)frame).delegate(caf::actor_cast(this)); }, - [=](jump_atom, const int frame) -> result { + [=](jump_atom, const int frame) { + return mail(jump_atom_v, frame, false).delegate(caf::actor_cast(this)); + }, + + [=](jump_atom, const int frame, bool force_audio_scrub) -> result { + auto rp = make_response_promise(); // by requesting duration from self we ensure that we have updated // internal data about source duration, incase this jump message @@ -366,9 +373,16 @@ void PlayheadActor::init() { .then( [=](const timebase::flicks flicks) mutable { + const bool user_is_scrubbing = user_is_frame_scrubbing_->value(); + if (force_audio_scrub && !user_is_scrubbing) { + user_is_frame_scrubbing_->set_value(true); + } set_position(flicks); update_child_playhead_positions(true); rp.deliver(true); + if (force_audio_scrub && !user_is_scrubbing) { + user_is_frame_scrubbing_->set_value(false); + } }, [=](const error &err) mutable { rp.deliver(err); }); } else { @@ -1186,6 +1200,9 @@ void PlayheadActor::init() { // timeline change event ... ignore as its taken care of by sub playhead }, + [=](utility::event_atom, timeline::audio_mode_atom, const timeline::AudioMode am) { + }, + [=](utility::event_atom, media_source_atom, utility::UuidActor media, @@ -1409,6 +1426,17 @@ void PlayheadActor::init() { [=](utility::event_atom, media::media_display_info_atom, const utility::JsonStore &) {}, + [=](xstudio::utility::event_atom, + xstudio::utility::change_atom, + xstudio::timeline::clip_edited_status_atom, + const utility::Uuid &, + const int , + const utility::Uuid &, + const utility::Uuid &, + const utility::Uuid &) { + // timeline clip edited status change event + }, + // controls creation and destruction of children [&](utility::event_atom, utility::change_atom) { if (current_sender() != this) { @@ -1753,7 +1781,7 @@ void PlayheadActor::make_audio_child_playhead(const int source_index) { } }; - if (timeline_mode()) { + if (timeline_mode() && PlayheadBase::uuid() != TIMELINE_COMPARE_PLAYHEAD_UUID) { // Are we already hooked up to the timeline as the audio source? if (audio_src_ == timeline_actor_ && audio_playhead_) return; diff --git a/src/playhead/src/playhead_global_events_actor.cpp b/src/playhead/src/playhead_global_events_actor.cpp index 07cbe30eb..e6d025085 100644 --- a/src/playhead/src/playhead_global_events_actor.cpp +++ b/src/playhead/src/playhead_global_events_actor.cpp @@ -28,13 +28,20 @@ PlayheadGlobalEventsActor::PlayheadGlobalEventsActor(caf::actor_config &cfg) // order to fully populate their toolbar. Also we need a playhead to exist // so that playhead related hotkeys are registere. Therefore we create a // dummy playhead here as a stand-in until real playheads are created by the session. - global_active_playhead_ = - spawn("DummyPlayhead", playhead::INDEPENDENT_AUDIO); - link_to(global_active_playhead_); + // + // The dummy is linked to us, so it must stay referenced for as long as we + // live: if its last strong reference is dropped it exits with reason + // 'unreachable' and the link propagates that exit to us, and from us to + // the GlobalActor. Hold it in its own member, not only in + // global_active_playhead_, which is reassigned once a real playhead exists. + dummy_playhead_ = spawn("DummyPlayhead", playhead::INDEPENDENT_AUDIO); + link_to(dummy_playhead_); + global_active_playhead_ = dummy_playhead_; } void PlayheadGlobalEventsActor::on_exit() { global_active_playhead_ = caf::actor(); + dummy_playhead_ = caf::actor(); viewports_.clear(); system().registry().erase(global_playhead_events_actor); } @@ -130,6 +137,10 @@ void PlayheadGlobalEventsActor::init() { return mail(jump_atom_v, frame).delegate(global_active_playhead_); }, + [=](jump_atom, const int frame, const bool force_audio_scrub) { + return mail(jump_atom_v, frame, force_audio_scrub).delegate(global_active_playhead_); + }, + [=](step_atom, const int frame) { return mail(step_atom_v, frame).delegate(global_active_playhead_); }, diff --git a/src/playhead/src/string_out_actor.cpp b/src/playhead/src/string_out_actor.cpp index 1a57706ab..379f0decd 100644 --- a/src/playhead/src/string_out_actor.cpp +++ b/src/playhead/src/string_out_actor.cpp @@ -80,6 +80,8 @@ StringOutActor::StringOutActor(caf::actor_config &cfg, const utility::UuidActorV [=](utility::event_atom, timeline::item_atom, const utility::JsonStore &changes, bool) { }, + [=](utility::event_atom, timeline::audio_mode_atom, const timeline::AudioMode am) {}, + [=](utility::event_atom, media::media_status_atom, const media::MediaStatus) {}, [=](json_store::update_atom, diff --git a/src/playhead/src/sub_playhead.cpp b/src/playhead/src/sub_playhead.cpp index 02b651e5f..ed092ea36 100644 --- a/src/playhead/src/sub_playhead.cpp +++ b/src/playhead/src/sub_playhead.cpp @@ -235,6 +235,26 @@ void SubPlayhead::init() { [=](utility::event_atom, utility::change_atom, media::rotation_atom, float) {}, + [=](xstudio::utility::event_atom, + xstudio::utility::change_atom, + xstudio::timeline::clip_edited_status_atom, + const utility::Uuid &, + const int , + const utility::Uuid &, + const utility::Uuid &, + const utility::Uuid &) { + // timeline clip edited status change event + }, + + [=](xstudio::utility::event_atom, + xstudio::utility::change_atom, + xstudio::timeline::clip_edited_status_atom, + const utility::Uuid &, + const int , + const utility::Uuid &) { + // timeline clip edited status change event + }, + [=](utility::event_atom, media::current_media_source_atom, UuidActor &a, @@ -720,6 +740,14 @@ void SubPlayhead::init() { anon_mail(source_atom_v).send(this); }, + [=](utility::event_atom, timeline::audio_mode_atom, const timeline::AudioMode am) { + if (media_type_ == media::MediaType::MT_AUDIO) { + up_to_date_ = false; + last_change_timepoint_ = utility::clock::now(); + anon_mail(source_atom_v).send(this); + } + }, + [=](utility::event_atom, media::media_status_atom, const media::MediaStatus) { // this can come from a MediActor that is our source_ }, diff --git a/src/playlist/src/playlist_actor.cpp b/src/playlist/src/playlist_actor.cpp index 5fa797fae..6db5117c0 100644 --- a/src/playlist/src/playlist_actor.cpp +++ b/src/playlist/src/playlist_actor.cpp @@ -367,6 +367,8 @@ caf::message_handler PlaylistActor::default_event_handler() { create_contact_sheet_atom, const utility::UuidActor &) {}, [=](utility::event_atom, create_timeline_atom, const utility::UuidActor &) {}, + [=](utility::event_atom, notification_atom, const caf::actor &,const JsonStore &jsn) {}, + [=](utility::event_atom, media_content_changed_atom, const utility::UuidActorVector &) {}, @@ -399,6 +401,11 @@ caf::message_handler PlaylistActor::message_handler() { } }, + [=](utility::event_atom, notification_atom, const JsonStore &jsn) { + auto src = caf::actor_cast(current_sender()); + mail(utility::event_atom_v, notification_atom_v, src, jsn).send(base_.event_group()); + }, + [=](utility::event_atom, timeline::item_atom, const utility::JsonStore &update, @@ -680,15 +687,19 @@ caf::message_handler PlaylistActor::message_handler() { // so that the duration of the media is known. This is because the playhead will // update and build a timeline as soon as the playlist notifies of change, so the // duration and frame rate must be known up-front + auto rp = make_response_promise(); std::vector media_actors; for (const auto &media : ma) { if (!media_.count(media.uuid())) { media_actors.push_back(media); } } + if (media_actors.empty()) { + rp.deliver(true); + return rp; + } auto source_count = std::make_shared(); (*source_count) = media_actors.size(); - auto rp = make_response_promise(); // add to lis first, then lazy update.. @@ -1081,6 +1092,31 @@ caf::message_handler PlaylistActor::message_handler() { const utility::JsonStore &, caf::actor_addr &) {}, + [=](utility::event_atom, timeline::audio_mode_atom, const timeline::AudioMode am) { + // forwarding audio mode change event from timeline to session + mail( + utility::event_atom_v, + timeline::audio_mode_atom_v, + am, + caf::actor_cast(current_sender()), + caf::actor_cast(this)) + .send(base_.event_group()); + }, + + [=](xstudio::utility::event_atom atom1, + xstudio::utility::change_atom atom2, + xstudio::timeline::clip_edited_status_atom atom3, + const utility::Uuid &clip_id, + const int status, + const utility::Uuid &track_id, + const utility::Uuid &stack_id, + const utility::Uuid &timeline_id) { + // forwarding clip edited status change event from timeline to session + // there MUST be a better way!!! + /*mail(atom1, atom2, atom3, clip_id, status, track_id, stack_id, timeline_id, base_.uuid()) + .send(base_.event_group());*/ + }, + [=](utility::event_atom, utility::change_atom, media::rotation_atom, float) {}, [=](utility::event_atom, media::media_display_info_atom, const utility::JsonStore &a) { diff --git a/src/plugin/colour_pipeline/ocio/src/ocio_plugin.cpp b/src/plugin/colour_pipeline/ocio/src/ocio_plugin.cpp index d8527476b..9d3cb1e33 100644 --- a/src/plugin/colour_pipeline/ocio/src/ocio_plugin.cpp +++ b/src/plugin/colour_pipeline/ocio/src/ocio_plugin.cpp @@ -161,7 +161,39 @@ caf::message_handler OCIOColourPipeline::message_handler_extensions() { redraw_viewport(); } } - }}) + }, + [=](get_colourspace_info_atom, caf::actor media_source) -> result { + // this message handler is used by the python plugin to get the colourspace options for + // the given media soure. + auto rp = make_response_promise(); + mail(get_colour_pipe_params_atom_v) + .request(media_source, infinite) + .then( + [=](const utility::JsonStore &colour_params) mutable { + + // Determine the source colourspace + std::string src_cs = m_engine_.detect_source_colourspace( + colour_params, global_settings_.untonemapped_mode); + + std::vector all_colourspaces; + std::vector displays; + std::map> display_views; + + m_engine_.get_ocio_displays_view_colourspaces( + colour_params, all_colourspaces, displays, display_views); + + utility::JsonStore result; + result["current_colourspace"] = src_cs; + result["available_colourspaces"] = all_colourspaces; + rp.deliver(result); + + }, + [=](const caf::error &err) mutable { + rp.deliver(err); + }); + return rp; + } + }) .or_else(ColourPipeline::message_handler_extensions()); } diff --git a/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py b/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py index 625ab2e46..e6c9860f7 100644 --- a/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py +++ b/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py @@ -3,8 +3,9 @@ import numpy as np -from xstudio.core import AttributeRole, Uuid +from xstudio.core import AttributeRole, Uuid, get_colourspace_info_atom from xstudio.plugin import PluginBase +import json # This plugin is intended to allow for OCIO helper operations that can be # executed in Python rather than C++. This is useful for operations that @@ -59,9 +60,9 @@ def __init__(self, connection): } ) - self.range_menu_id = self.insert_menu_item( + self.insert_menu_item( menu_model_name="media_list_menu_", - menu_text="Set YUV Range", + menu_text="YUV Range", menu_path="Media Settings", menu_item_position=1.0, attr_id=self.range_attr.uuid, @@ -78,14 +79,32 @@ def __init__(self, connection): } ) - self.matrix_menu_id = self.insert_menu_item( + self.insert_menu_item( menu_model_name="media_list_menu_", - menu_text="Set YUV Matrix", + menu_text="YUV Matrix", menu_path="Media Settings", menu_item_position=2.0, attr_id=self.matrix_attr.uuid, ) + # Colourspace setting + + self.source_colourspace_attr = self.add_attribute( + attribute_name="Source Colourspace", + attribute_value="", + attribute_role_data={ + "combo_box_options": ["Unknown"] + } + ) + + self.insert_menu_item( + menu_model_name="media_list_menu_", + menu_text="OCIO Source Colourspace", + menu_path="Media Settings", + menu_item_position=2.1, + attr_id=self.source_colourspace_attr.uuid, + ) + self.connect_to_ui() def menu_item_shown(self, menu_item_data, user_data): @@ -100,6 +119,10 @@ def menu_item_shown(self, menu_item_data, user_data): """ shown_uuid = Uuid(menu_item_data["uuid"]) + if shown_uuid == self.source_colourspace_attr.uuid: + self.fill_source_colourspace_menu() + return + # Always base the Auto label off the main selection media = self.connection.api.session.selected_media[0] media_metadata = self._yuv_media_metadata(media) @@ -173,7 +196,15 @@ def attribute_changed(self, attribute, role): medias = self.connection.api.session.selected_media - if attribute == self.range_attr: + if attribute == self.source_colourspace_attr: + + source_cs = self.source_colourspace_attr.value() + if source_cs == "Reset": + source_cs = "" + for media in medias: + media.media_source().set_metadata(source_cs, '/colour_pipeline/override_input_cs') + + elif attribute == self.range_attr: range_value = self.range_attr.value() for media in medias: @@ -205,6 +236,33 @@ def attribute_changed(self, attribute, role): # Update the shader overrides based on the new settings self._update_yuv_media_conversion(media) + def fill_source_colourspace_menu(self): + """Fill the source colourspace menu with available colourspaces.""" + # Get the list of available colourspaces from OCIO + # For now, we will use a hardcoded list for demonstration purposes + try: + selected_media = self.connection.api.session.inspected_container.playhead_selection.selected_sources + if selected_media: + cp_actor = self.connection.api.app.active_viewport.colour_pipeline.remote + cs_info = self.connection.request_receive( + cp_actor, + get_colourspace_info_atom(), + selected_media[0].media_source().remote)[0] + cs_info = json.loads(cs_info.dump()) + + available_colourspaces = cs_info["available_colourspaces"] + self.source_colourspace_attr.set_role_data( + "combo_box_options", ["Reset"] + available_colourspaces + ) + self.source_colourspace_attr.set_value(cs_info["current_colourspace"], False) + else: + self.source_colourspace_attr.set_role_data( + "combo_box_options", [] + ) + except Exception as e: + self.connection.api.log_err(f"Error filling source colourspace menu: {e}") + + def _update_yuv_media_conversion(self, media): media_metadata = self._yuv_media_metadata(media) plugin_metadata = self._plugin_metadata(media) diff --git a/src/plugin/conform/dneg/conform_shotbrowser/src/conform_shotbrowser.cpp b/src/plugin/conform/dneg/conform_shotbrowser/src/conform_shotbrowser.cpp index 1d7f13ed4..d7114f5d1 100644 --- a/src/plugin/conform/dneg/conform_shotbrowser/src/conform_shotbrowser.cpp +++ b/src/plugin/conform/dneg/conform_shotbrowser/src/conform_shotbrowser.cpp @@ -788,8 +788,14 @@ template class ShotbrowserConformActor : public caf::event_based_ac // from check clip metadata (FEAT ANIM) if (not is_valid and not found_project.empty()) { auto cm = i.prop(); // DNEG_MEDIA_STALK_DNUUID ? - if (cm.contains("media_stalk_dnuuid") or - cm.contains("DNEG_MEDIA_STALK_DNUUID")) { + + // we dynamically write DNEG_MEDIA_STALK_DNUUID back into clips. + // so we can't reliably use this to determine if we're using edit ref mode. + // check clip name it shouldn't have a dot in it.. + // should be check media_reference is pointing to an edit ref ? + + if (std::string::npos == i.name().find_first_of(".") and (cm.contains("media_stalk_dnuuid") or + cm.contains("DNEG_MEDIA_STALK_DNUUID"))) { project = found_project; cut_start = i.trimmed_frame_start().frames(); diff --git a/src/plugin/data_source/demo/demo_datasource_plugin/src/CMakeLists.txt b/src/plugin/data_source/demo/demo_datasource_plugin/src/CMakeLists.txt index 1fc7e479d..2d7fe4665 100644 --- a/src/plugin/data_source/demo/demo_datasource_plugin/src/CMakeLists.txt +++ b/src/plugin/data_source/demo/demo_datasource_plugin/src/CMakeLists.txt @@ -63,6 +63,7 @@ set_target_properties(${PROJECT_NAME} PROPERTIES LINK_DEPENDS_NO_SHARED true) qt_add_qml_module(${PROJECT_NAME} URI DemoPlugin + OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/DemoPlugin VERSION 1.0 NO_GENERATE_PLUGIN_SOURCE NO_PLUGIN_OPTIONAL diff --git a/src/plugin/data_source/dneg/ivy/src/data_source_ivy.cpp b/src/plugin/data_source/dneg/ivy/src/data_source_ivy.cpp index 992822426..6ad08dc69 100644 --- a/src/plugin/data_source/dneg/ivy/src/data_source_ivy.cpp +++ b/src/plugin/data_source/dneg/ivy/src/data_source_ivy.cpp @@ -442,6 +442,7 @@ template caf::message_handler IvyDataSourceActor::message_handle const caf::uri &uri, const FrameRate &media_rate, const bool create_playlist) -> result { + if (uri.scheme() != "ivy") return UuidActorVector(); @@ -466,7 +467,7 @@ IvyDataSourceActor::IvyDataSourceActor(caf::actor_config &cfg, const utility: data_source_.set_parent_actor_addr(actor_cast(this)); - http_ = spawn(CPPHTTPLIB_CONNECTION_TIMEOUT_SECOND, 20, 20); + http_ = spawn(CPPHTTPLIB_CONNECTION_TIMEOUT_SECOND, 60, 60); link_to(http_); try { @@ -627,6 +628,7 @@ void IvyMediaWorker::add_media_source( auto actor = std::string(); auto character = std::string(); auto language = std::string(); + auto camera = std::string(); for (const auto &nt : jsn.at("version").at("name_tags")) { if (nt.at("name") == "label") { @@ -641,11 +643,13 @@ void IvyMediaWorker::add_media_source( character = nt.at("value"); } else if (nt.at("name") == "language") { language = nt.at("value"); + } else if (nt.at("name") == "camera_type") { + camera = nt.at("value"); } } - if (not actor.empty() or not character.empty() or not language.empty()) { - name = join_as_string({type, actor, character, language, label}, "-", true); + if (not actor.empty() or not character.empty() or not language.empty() or not camera.empty()) { + name = join_as_string({type, actor, character, camera, language, label}, "-", true); } else if (not label.empty() and not type.empty()) { name = label + "-" + type; } else if ( @@ -1285,6 +1289,7 @@ void IvyDataSourceActor::ivy_load_file( (*count)--; if (not ua.uuid().is_null()) { auto uuid = utility::Uuid::generate(); + auto media = spawn( i.at("version").at("name"), uuid, diff --git a/src/plugin/data_source/dneg/shotbrowser/python/src/xstudio_shotbrowser/__init__.py b/src/plugin/data_source/dneg/shotbrowser/python/src/xstudio_shotbrowser/__init__.py index 0c39c525b..e654ed5a4 100644 --- a/src/plugin/data_source/dneg/shotbrowser/python/src/xstudio_shotbrowser/__init__.py +++ b/src/plugin/data_source/dneg/shotbrowser/python/src/xstudio_shotbrowser/__init__.py @@ -13,12 +13,15 @@ def __init__(self, connection): """ ac = connection.get_actor_from_registry("SHOTBROWSER") + # extra long timeout for ShotBrowser, as it can take a while to get data from ShotGrid + self.default_timeout_ms = 60000 ActorConnection.__init__(self, ac.connection, ac.remote) def get_data(self, data_type, project_id=-1): request = {"operation": "GetData", "type": data_type, "project_id": project_id} - return self.connection.request_receive( + return self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, get_data_atom(), JsonStore(request) @@ -26,7 +29,8 @@ def get_data(self, data_type, project_id=-1): def get_projects(self): request = {"operation": "GetData", "type": "project", "project_id": 0} - return self.connection.request_receive( + return self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, get_data_atom(), JsonStore(request) @@ -34,7 +38,8 @@ def get_projects(self): def get_project_sequence(self, project_id): request = {"operation": "GetData", "type": "sequence", "project_id": project_id} - return self.connection.request_receive( + return self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, get_data_atom(), JsonStore(request) @@ -42,7 +47,8 @@ def get_project_sequence(self, project_id): def get_project_episode(self, project_id): request = {"operation": "GetData", "type": "episode", "project_id": project_id} - return self.connection.request_receive( + return self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, get_data_atom(), JsonStore(request) @@ -50,7 +56,8 @@ def get_project_episode(self, project_id): def get_project_shot(self, project_id): request = {"operation": "GetData", "type": "sequence_shot", "project_id": project_id} - return self.connection.request_receive( + return self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, get_data_atom(), JsonStore(request) @@ -58,7 +65,8 @@ def get_project_shot(self, project_id): def is_shotgrid_login_allowed(self): request = {"operation": "GetData", "type": "is_shotgrid_login_allowed"} - return bool(self.connection.request_receive( + return bool(self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, get_data_atom(), JsonStore(request) @@ -69,11 +77,11 @@ def get_project_scope(self, project_id): shot = self.get_project_shot(project_id) return seq.get() + shot.get() - def update_entity(self, entity, record_id, body, fields=None): if fields is None: fields = [] - return self.connection.request_receive( + return self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, shotgun_update_entity_atom(), entity, @@ -82,7 +90,8 @@ def update_entity(self, entity, record_id, body, fields=None): VectorString([] if fields is None else fields))[0] def get_entity(self, entity, record_id, fields=None): - return self.connection.request_receive( + return self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, shotgun_entity_atom(), entity, @@ -90,7 +99,8 @@ def get_entity(self, entity, record_id, fields=None): VectorString([] if fields is None else fields))[0] def get_entities_search(self, entity, conditions, fields=None, sort=None, page=1, page_size=4999): - return self.connection.request_receive( + return self.connection.request_receive_timeout( + self.default_timeout_ms, self.remote, shotgun_entity_search_atom(), entity, diff --git a/src/plugin/data_source/dneg/shotbrowser/share/preference/plugin_data_source_shotbrowser.json b/src/plugin/data_source/dneg/shotbrowser/share/preference/plugin_data_source_shotbrowser.json index 9248b5718..8779e305a 100644 --- a/src/plugin/data_source/dneg/shotbrowser/share/preference/plugin_data_source_shotbrowser.json +++ b/src/plugin/data_source/dneg/shotbrowser/share/preference/plugin_data_source_shotbrowser.json @@ -505,36 +505,6 @@ "description": "Additional Presets Paths", "path": "/plugin/data_source/shotbrowser/preset_paths", "value": ["/tools/SITE/data/xStudio/shotbrowser/presets", "${HOME}/xStudio/presets", "/tools/${SHOW}/xStudio/presets"] - }, - "project_presets": { - "context": [ - "PLUGIN" - ], - "datatype": "json", - "default_value": [], - "description": "Project presets.", - "path": "/plugin/data_source/shotbrowser/project_presets", - "value": null - }, - "site_presets": { - "context": [ - "PLUGIN" - ], - "datatype": "json", - "default_value": [], - "description": "Site presets.", - "path": "/plugin/data_source/shotbrowser/site_presets", - "value": null - }, - "user_presets": { - "context": [ - "PLUGIN" - ], - "datatype": "json", - "default_value": [], - "description": "User presets.", - "path": "/plugin/data_source/shotbrowser/user_presets", - "value": null } } } diff --git a/src/plugin/data_source/dneg/shotbrowser/src/definitions.hpp b/src/plugin/data_source/dneg/shotbrowser/src/definitions.hpp index 5e2988f57..05d6020b9 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/definitions.hpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/definitions.hpp @@ -294,6 +294,7 @@ const auto SequenceShotFields = std::vector( "code", "sg_asset_type", "sg_current_stage", + "sg_cut_order", "sg_dnbreakdown_assemblies", "sg_dnbreakdown_envprop_variants", "sg_dnuuid", @@ -301,6 +302,7 @@ const auto SequenceShotFields = std::vector( "sg_primary_shot_location", "sg_shot_type", "sg_status_list", + "sg_transition_type", "sg_unit", "tags"}); @@ -323,6 +325,7 @@ const auto ShotFields = std::vector( "sg_primary_shot_location", "sg_shot_type", "sg_status_list", + "sg_transition_type", "sg_unit", "tags"}); @@ -423,6 +426,7 @@ const auto TwigTypeCodes = xstudio::utility::JsonStore(R"([ {"id": "lng", "name": "lensgrid"}, {"id": "ladj", "name": "lighting_adjust"}, {"id": "look", "name": "look"}, + {"id": "mls", "name": "mlsource"}, {"id": "mtdd", "name": "material_data_driven"}, {"id": "mtddcfg", "name": "material_data_driven_config"}, {"id": "mtpc", "name": "material_plus_config"}, diff --git a/src/plugin/data_source/dneg/shotbrowser/src/get_actions.cpp b/src/plugin/data_source/dneg/shotbrowser/src/get_actions.cpp index bd2cb85df..4f221e991 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/get_actions.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/get_actions.cpp @@ -2114,11 +2114,19 @@ void ShotBrowser::get_data_sequence( QueryEngine::cache_name("Sequence", project_id), total, engine().lookup()); + engine().set_reference_cache( QueryEngine::cache_name( "Reference", project_id), total, engine().cache()); + + engine().set_lookup( + QueryEngine::cache_name("Reference", project_id), + *engine().get_cache(QueryEngine::cache_name("Reference", project_id)), + engine().lookup()); + + engine().set_shot_sequence_lookup( QueryEngine::cache_name( "ShotSequence", project_id), diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/ShotBrowserHelpers.js b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/ShotBrowserHelpers.js index f4424a8f7..ad4a0d2f8 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/ShotBrowserHelpers.js +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/ShotBrowserHelpers.js @@ -37,6 +37,25 @@ function revealMediaInShotgrid(indexes=[]) { } } +function createReferencePlaylists(project_id) { + // get list of reference stems. + let context = createContext() + + Future.promise( + ShotBrowserEngine.getReferenceStemsFuture(project_id) + ).then(function(sequences) { + if(sequences.length) { + context["theSessionData"].createDivider("Reference Playlists") + for(let i=0; i listDiv.playMovie(path) + + property bool wasHovered: false + onDescriptionClickedChanged: { + if(descriptionClicked) + wasHovered = true + } + + height: compactMode ? XsStyleSheet.widgetStdHeight+2 : ((XsStyleSheet.widgetStdHeight + 1) * 4) + (wasHovered ? Math.max(textHeightDiff, 0) : 0) } } diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDelegate.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDelegate.qml index 264e45bd3..3fcbd057a 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDelegate.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDelegate.qml @@ -37,6 +37,7 @@ Rectangle{ id: frame required property string pipelineStatusFullRole required property string authorRole required property string thumbRole + required property string descriptionRole required property string clientFilenameRole required property string clientVersionRole required property string projectRole @@ -64,8 +65,11 @@ Rectangle{ id: frame property bool compactMode: false + property alias textHeightDiff: sec2.textHeightDiff - property bool isHovered: mArea.containsMouse || versionArrowBtn.hovered || sec1.playerMA.containsMouse || iconState.containsMouse + property alias descriptionClicked: sec2.descriptionClicked + + property bool isHovered: mArea.containsMouse || versionArrowBtn.hovered || sec1.playerMA.containsMouse || iconState.containsMouse || sec2.descriptionHovered signal playMovie(path: var) @@ -168,6 +172,8 @@ Rectangle{ id: frame RowLayout{ Layout.fillWidth: true + Layout.fillHeight: true + Layout.maximumHeight: XsStyleSheet.widgetStdHeight Layout.minimumHeight: XsStyleSheet.widgetStdHeight spacing: itemSpacing visible: compactMode @@ -260,7 +266,8 @@ Rectangle{ id: frame Rectangle{ Layout.fillWidth: true Layout.fillHeight: true - Layout.preferredHeight: XsStyleSheet.widgetStdHeight + Layout.maximumHeight: XsStyleSheet.widgetStdHeight + Layout.minimumHeight: XsStyleSheet.widgetStdHeight color: XsStyleSheet.widgetBgNormalColor visible: !compactMode @@ -287,7 +294,7 @@ Rectangle{ id: frame RowLayout{ Layout.fillWidth: true - Layout.preferredHeight: (XsStyleSheet.widgetStdHeight * rowCount) + (spacing * (rowCount-1)) + Layout.preferredHeight: (XsStyleSheet.widgetStdHeight * rowCount) + (spacing * rowCount) spacing: itemSpacing x: spacing visible: !compactMode @@ -297,7 +304,7 @@ Rectangle{ id: frame ShotHistorySection1{ id: sec1 Layout.minimumWidth: 154 - Layout.preferredWidth: 154 + Layout.maximumWidth: 154 Layout.fillHeight: true } @@ -311,7 +318,7 @@ Rectangle{ id: frame ShotHistorySection3{ id: sec3 Layout.minimumWidth: 157 - Layout.preferredWidth: 157 + Layout.maximumWidth: 157 Layout.fillHeight: true } } diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDiv.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDiv.qml index 321c6e457..81457f0d6 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDiv.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDiv.qml @@ -73,11 +73,20 @@ XsListView { model: dataModel delegate: ShotHistoryListDelegate{ width: list.width - rightSpacing - height: compactMode ? XsStyleSheet.widgetStdHeight+2 : (XsStyleSheet.widgetStdHeight + 1) * 4 delegateModel: chooserModel popupMenu: resultPopup onPlayMovie: (path) => list.playMovie(path) compactMode: list.compactMode + + property bool wasHovered: false + + onDescriptionClickedChanged: { + if(descriptionClicked) + wasHovered = true + } + + height: compactMode ? XsStyleSheet.widgetStdHeight+2 : ((XsStyleSheet.widgetStdHeight + 1) * 4) + (wasHovered ? Math.max(textHeightDiff, 0) : 0) + } } diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryTitleDiv.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryTitleDiv.qml index d2841eefe..852879fb5 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryTitleDiv.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryTitleDiv.qml @@ -97,17 +97,17 @@ RowLayout {id: titleDiv } } - XsPrimaryButton{ - Layout.leftMargin: 2 - Layout.preferredWidth: XsStyleSheet.primaryButtonStdWidth - Layout.maximumWidth: XsStyleSheet.primaryButtonStdWidth - Layout.fillHeight: true - - imgSrc: "qrc:/icons/list_alt2.svg" - toolTip: "Compact Mode" - isActive: compactMode - onClicked: compactMode = !compactMode - } + // XsPrimaryButton{ + // Layout.leftMargin: 2 + // Layout.preferredWidth: XsStyleSheet.primaryButtonStdWidth + // Layout.maximumWidth: XsStyleSheet.primaryButtonStdWidth + // Layout.fillHeight: true + + // imgSrc: "qrc:/icons/list_alt2.svg" + // toolTip: "Compact Mode" + // isActive: compactMode + // onClicked: compactMode = !compactMode + // } XsPrimaryButton{ Layout.leftMargin: 2 diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection1.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection1.qml index 93f9d524b..2a000b021 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection1.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection1.qml @@ -6,199 +6,210 @@ import QtQuick.Effects import xStudio 1.0 import ShotBrowser 1.0 - -Rectangle{ - color: XsStyleSheet.widgetBgNormalColor +ColumnLayout { + spacing: 0 property alias playerMA: playerMA - RowLayout { - anchors.fill: parent - spacing: itemSpacing - - Rectangle{ id: indicators - Layout.preferredWidth: 20 - Layout.fillHeight: true - color: "transparent" - - Column{ - y: spacing - anchors.fill: parent - spacing: itemSpacing - visible: linkMode - - XsPrimaryButton{ - property bool hasTags: tagRole && tagRole.length ? true : false - text: "L" - width: 20 - height: parent.height - font.pixelSize: textSize*1.2 - font.weight: hasTags? Font.Bold:Font.Medium - isUnClickable: true - isActiveViaIndicator: false - textDiv.color: hasTags? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor - enabled: false - bgDiv.opacity: enabled? 1.0 : 0.5 - isActive: hasTags - } - } + Rectangle{ + color: XsStyleSheet.widgetBgNormalColor + Layout.fillHeight: true + Layout.fillWidth: true + Layout.maximumHeight: XsStyleSheet.widgetStdHeight * 3 + Layout.minimumHeight: XsStyleSheet.widgetStdHeight * 3 - Column{ - y: spacing - anchors.fill: parent - spacing: itemSpacing - visible: !linkMode - - XsPrimaryButton{ id: notesIndicatorDisplay - property bool hasNotes: noteCountRole <= 0 ? false : true - text: "N" - width: 20 - height: parent.height/3 - itemSpacing - font.pixelSize: textSize*1.2 - font.weight: hasNotes? Font.Bold:Font.Medium - isUnClickable: true - isActiveViaIndicator: false - textDiv.color: hasNotes? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor - enabled: false - bgDiv.opacity: enabled? 1.0 : 0.5 - isActive: hasNotes - } - XsPrimaryButton{ id: dailiesIndicatorDisplay - property bool hasDailies: submittedToDailiesRole === undefined ? false :true - text: "D" //dalies - width: 20 - height: parent.height/3 - itemSpacing - font.pixelSize: textSize*1.2 - font.weight: hasDailies? Font.Bold:Font.Medium - isUnClickable: true - isActiveViaIndicator: false - textDiv.color: hasDailies? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor - enabled: false - bgDiv.opacity: enabled? 1.0 : 0.5 - isActive: hasDailies - } - XsPrimaryButton{ id: clientIndicatorDisplay - property bool hasClient: dateSubmittedToClientRole === undefined ? false : true - text: "C" //client - width: 20 - height: parent.height/3 - itemSpacing - font.pixelSize: textSize*1.2 - font.weight: hasClient? Font.Bold:Font.Medium - isUnClickable: true - isActiveViaIndicator: false - textDiv.color: hasClient? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor - enabled: false - bgDiv.opacity: enabled? 1.0 : 0.5 - isActive: hasClient - } - - } - - } + RowLayout { + anchors.fill: parent + spacing: itemSpacing - Item{ id: thumbImage - Layout.fillWidth: true - Layout.fillHeight: true + Rectangle{ id: indicators + Layout.preferredWidth: 20 + Layout.fillHeight: true + color: "transparent" - property bool failed: thumbRole == undefined + Column{ + y: spacing + anchors.fill: parent + spacing: itemSpacing + visible: linkMode + + XsPrimaryButton{ + property bool hasTags: tagRole && tagRole.length ? true : false + text: "L" + width: 20 + height: parent.height + font.pixelSize: textSize*1.2 + font.weight: hasTags? Font.Bold:Font.Medium + isUnClickable: true + isActiveViaIndicator: false + textDiv.color: hasTags? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor + enabled: false + bgDiv.opacity: enabled? 1.0 : 0.5 + isActive: hasTags + } + } - Rectangle{ - width: parent.width - height: Math.min(width / (16/9), thumbImage.height) - 10 - color: Qt.lighter(panelColor, 1.1) - visible: parent.failed - anchors.verticalCenter: parent.verticalCenter - } - XsText{ id: noThumbnailDisplay - text: parent.failed? "No ShotGrid\nThumbnail":"Loading..." - width: thumbnail.width - height: thumbnail.height - anchors.centerIn: parent - wrapMode: Text.WordWrap - font.pixelSize: textSize*1.2 - font.weight: Font.Black - opacity: parent.failed? 0.4 : 0.9 - } + Column{ + y: spacing + anchors.fill: parent + spacing: itemSpacing + visible: !linkMode + + XsPrimaryButton{ id: notesIndicatorDisplay + property bool hasNotes: noteCountRole <= 0 ? false : true + text: "N" + width: 20 + height: parent.height/3 - itemSpacing + font.pixelSize: textSize*1.2 + font.weight: hasNotes? Font.Bold:Font.Medium + isUnClickable: true + isActiveViaIndicator: false + textDiv.color: hasNotes? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor + enabled: false + bgDiv.opacity: enabled? 1.0 : 0.5 + isActive: hasNotes + } + XsPrimaryButton{ id: dailiesIndicatorDisplay + property bool hasDailies: submittedToDailiesRole === undefined ? false :true + text: "D" //dalies + width: 20 + height: parent.height/3 - itemSpacing + font.pixelSize: textSize*1.2 + font.weight: hasDailies? Font.Bold:Font.Medium + isUnClickable: true + isActiveViaIndicator: false + textDiv.color: hasDailies? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor + enabled: false + bgDiv.opacity: enabled? 1.0 : 0.5 + isActive: hasDailies + } + XsPrimaryButton{ id: clientIndicatorDisplay + property bool hasClient: dateSubmittedToClientRole === undefined ? false : true + text: "C" //client + width: 20 + height: parent.height/3 - itemSpacing + font.pixelSize: textSize*1.2 + font.weight: hasClient? Font.Bold:Font.Medium + isUnClickable: true + isActiveViaIndicator: false + textDiv.color: hasClient? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor + enabled: false + bgDiv.opacity: enabled? 1.0 : 0.5 + isActive: hasClient + } - XsImage{ id: thumbnail - visible: !parent.failed - width: parent.width - height: Math.min(width / (16/9), thumbImage.height) - source: parent.failed || thumbRole==undefined? "": thumbRole - fillMode: Image.PreserveAspectFit - anchors.verticalCenter: parent.verticalCenter - - clip: true - cache: true - - opacity: 0 - Behavior on opacity {NumberAnimation {duration: 150}} - - onStatusChanged: { - if (status == Image.Null) { parent.failed=true; opacity=0 } - else if (status == Image.Error) { parent.failed=true; opacity=0; noThumbnailDisplay.text= "Thumbnail\nError" } - else if (status == Image.Ready) opacity=1 - else opacity=0 } - } + } - MouseArea { - id: playerMA + Item{ id: thumbImage + Layout.fillWidth: true + Layout.fillHeight: true - property bool movieFailed: false + property bool failed: thumbRole == undefined - anchors.left: parent.left - anchors.top: parent.top - anchors.margins: 4 - height: parent.height / 3 - width: height + Rectangle{ + width: parent.width + height: Math.min(width / (16/9), thumbImage.height) - 10 + color: Qt.lighter(panelColor, 1.1) + visible: parent.failed + anchors.verticalCenter: parent.verticalCenter + } + XsText{ id: noThumbnailDisplay + text: parent.failed? "No ShotGrid\nThumbnail":"Loading..." + width: thumbnail.width + height: thumbnail.height + anchors.centerIn: parent + wrapMode: Text.WordWrap + font.pixelSize: textSize*1.2 + font.weight: Font.Black + opacity: parent.failed? 0.4 : 0.9 + } - enabled: !parent.failed - onClicked: { - let url = ShotBrowserEngine.downloadMedia(idRole, nameRole, projectRole, entityRole) - if(url != "") - playMovie(url) - else { - movieFailed = true + XsImage{ id: thumbnail + visible: !parent.failed + width: parent.width + height: Math.min(width / (16/9), thumbImage.height) + source: parent.failed || thumbRole==undefined? "": thumbRole + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + + clip: true + cache: true + + opacity: 0 + Behavior on opacity {NumberAnimation {duration: 150}} + + onStatusChanged: { + if (status == Image.Null) { parent.failed=true; opacity=0 } + else if (status == Image.Error) { parent.failed=true; opacity=0; noThumbnailDisplay.text= "Thumbnail\nError" } + else if (status == Image.Ready) opacity=1 + else opacity=0 } } - hoverEnabled: true - Item { - anchors.fill: parent - visible: isHovered && playerMA.enabled - - // Note: we can't use 'icon' as source for MultiEffect as - // XsIcon already has a MultiEffect layer and this is - // buggy in QML - MultiEffect { - source: img - anchors.fill: icon - shadowBlur: 0.1 - shadowEnabled: true - shadowColor: "black" - shadowVerticalOffset: 2 - shadowHorizontalOffset: 2 - } - Image { + MouseArea { + id: playerMA - id: img - anchors.fill: parent - source: "qrc:/icons/play_circle.svg" - sourceSize.height: height - sourceSize.width: width - visible: false + property bool movieFailed: false + + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 4 + height: parent.height / 3 + width: height + + enabled: !parent.failed + onClicked: { + let url = ShotBrowserEngine.downloadMedia(idRole, nameRole, projectRole, entityRole) + if(url != "") + playMovie(url) + else { + movieFailed = true + } } + hoverEnabled: true - XsIcon{ - id: icon + Item { anchors.fill: parent - source: "qrc:/icons/play_circle.svg" - imgOverlayColor: playerMA.movieFailed ? "red" : (playerMA.containsMouse ? XsStyleSheet.primaryTextColor : XsStyleSheet.accentColor) + visible: isHovered && playerMA.enabled + + // Note: we can't use 'icon' as source for MultiEffect as + // XsIcon already has a MultiEffect layer and this is + // buggy in QML + MultiEffect { + source: img + anchors.fill: icon + shadowBlur: 0.1 + shadowEnabled: true + shadowColor: "black" + shadowVerticalOffset: 2 + shadowHorizontalOffset: 2 + } + + Image { + + id: img + anchors.fill: parent + source: "qrc:/icons/play_circle.svg" + sourceSize.height: height + sourceSize.width: width + visible: false + } + + XsIcon{ + id: icon + anchors.fill: parent + source: "qrc:/icons/play_circle.svg" + imgOverlayColor: playerMA.movieFailed ? "red" : (playerMA.containsMouse ? XsStyleSheet.primaryTextColor : XsStyleSheet.accentColor) + } } } } } } + Item{ + Layout.fillWidth: true + Layout.fillHeight: true + } } \ No newline at end of file diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection2.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection2.qml index 3c1890547..9471dd156 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection2.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection2.qml @@ -2,19 +2,30 @@ import QtQuick import QtQuick.Layouts +import QtQuick.Controls.Basic + import xStudio 1.0 import ShotBrowser 1.0 Rectangle{ color: "transparent" + property bool descriptionClicked: false + property bool descriptionHovered: descriptionEdit.hovered + property int textHeightDiff: 0 + + Component.onCompleted: { + textHeightDiff = descriptionEdit.implicitHeight - descriptionEdit.height + } + ColumnLayout { anchors.fill: parent spacing: itemSpacing ShotHistoryTextRow{ id: authorDiv Layout.fillWidth: true - Layout.fillHeight: true + Layout.maximumHeight: XsStyleSheet.widgetStdHeight + Layout.minimumHeight: XsStyleSheet.widgetStdHeight textDiv.leftPadding: panelPadding textDiv.horizontalAlignment: Text.AlignLeft @@ -22,25 +33,78 @@ Rectangle{ text: authorRole textColor: XsStyleSheet.primaryTextColor } - ShotHistoryTextRow{ id: dateDiv + RowLayout { Layout.fillWidth: true - Layout.minimumHeight: XsStyleSheet.widgetStdHeight + Layout.fillHeight: true + spacing: itemSpacing + ShotHistoryTextRow{ id: dateDiv + Layout.fillWidth: true + Layout.minimumHeight: XsStyleSheet.widgetStdHeight - textDiv.leftPadding: panelPadding - textDiv.horizontalAlignment: Text.AlignLeft + textDiv.leftPadding: panelPadding + textDiv.horizontalAlignment: Text.AlignLeft + + property var dateFormatted: createdDateRole.toLocaleString().split(" ") + + text: typeof dateFormatted !== 'undefined'? dateFormatted[1].substr(0,3)+" "+dateFormatted[2]+" "+dateFormatted[3] : "" + } + ShotHistoryTextRow{ id: frameRangeDiv + Layout.fillWidth: true + Layout.minimumHeight: XsStyleSheet.widgetStdHeight - property var dateFormatted: createdDateRole.toLocaleString().split(" ") + textDiv.leftPadding: panelPadding + textDiv.horizontalAlignment: Text.AlignLeft - text: typeof dateFormatted !== 'undefined'? dateFormatted[1].substr(0,3)+" "+dateFormatted[2]+" "+dateFormatted[3] : "" + text: frameRangeRole + } } - ShotHistoryTextRow{ id: frameRangeDiv + + Rectangle{ id: descriptionDiv Layout.fillWidth: true - Layout.minimumHeight: XsStyleSheet.widgetStdHeight + Layout.fillHeight: true + color: XsStyleSheet.widgetBgNormalColor + + TapHandler { + onTapped: descriptionClicked = true + } - textDiv.leftPadding: panelPadding - textDiv.horizontalAlignment: Text.AlignLeft - text: frameRangeRole + TextArea{ id: descriptionEdit + anchors.fill: parent + enabled: false + readOnly: true + + font.pixelSize: XsStyleSheet.fontSize + font.family: XsStyleSheet.fontFamily + font.hintingPreference: Font.PreferNoHinting + color: XsStyleSheet.primaryTextColor + + text: descriptionRole + padding: panelPadding + wrapMode: TextEdit.Wrap + } + + XsIcon{ + width: XsStyleSheet.secondaryButtonStdWidth + height: XsStyleSheet.secondaryButtonStdWidth + anchors.right: parent.right + anchors.rightMargin: 7 + anchors.bottom: parent.bottom + anchors.bottomMargin: 2 + imgOverlayColor: XsStyleSheet.accentColor + source: "qrc:///shotbrowser_icons/arrow_right.svg" + visible: descriptionEdit.implicitHeight > descriptionEdit.height + rotation: 90 + } } + // ShotHistoryTextRow{ + // Layout.fillWidth: true + // Layout.minimumHeight: XsStyleSheet.widgetStdHeight + + // textDiv.leftPadding: panelPadding + // textDiv.horizontalAlignment: Text.AlignLeft + + // text: descriptionRole + // } } } \ No newline at end of file diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection3.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection3.qml index f27cc9b1f..c7824b90b 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection3.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/sections/ShotHistorySection3.qml @@ -7,15 +7,17 @@ import ShotBrowser 1.0 Rectangle{ color: "transparent" + id: control ColumnLayout { anchors.fill: parent spacing: itemSpacing RowLayout { + Layout.alignment: Qt.AlignTop Layout.fillWidth: true Layout.maximumHeight: XsStyleSheet.widgetStdHeight - Layout.preferredHeight: XsStyleSheet.widgetStdHeight + Layout.minimumHeight: XsStyleSheet.widgetStdHeight spacing: itemSpacing @@ -35,90 +37,81 @@ Rectangle{ } ShotHistoryTextRow{ id: prodDiv + Layout.alignment: Qt.AlignTop Layout.fillWidth: true - Layout.preferredHeight: XsStyleSheet.widgetStdHeight Layout.maximumHeight: XsStyleSheet.widgetStdHeight + Layout.minimumHeight: XsStyleSheet.widgetStdHeight text: productionStatusFullRole textColor: XsStyleSheet.primaryTextColor textDiv.width: width } - Rectangle{ id: siteDiv + RowLayout { id: siteDiv Layout.fillWidth: true - Layout.minimumHeight: XsStyleSheet.widgetStdHeight - color: XsStyleSheet.widgetBgNormalColor - - Grid{ id: siteGrid - width: parent.width - itemSpacing*(siteModel.count-1) - height: parent.height - anchors.verticalCenter: parent.verticalCenter - rows: 1 - columns: siteModel.count - spacing: itemSpacing - flow: Grid.LeftToRight - - Repeater{ - model: siteModel - - XsPrimaryButton{ - - property int onDisk: { - if(index==0) onSiteChn - else if(index==1) onSiteLon - else if(index==2) onSiteMtl - else if(index==3) onSiteMum - else if(index==4) onSiteSyd - // else if(index==5) onSiteVan - else false - } + Layout.alignment: Qt.AlignTop + Layout.minimumHeight: XsStyleSheet.widgetStdHeight-1 + Layout.maximumHeight: XsStyleSheet.widgetStdHeight-1 - property real desiredWidth: siteGrid.width/siteModel.count > 40? 40 : siteGrid.width/siteModel.count + readonly property var panelColorLight: Qt.lighter(panelColor, 1.5) - width: desiredWidth - height: siteGrid.height + spacing: itemSpacing - isUnClickable: true - enabled: false - text: siteName - textDiv.color: onDisk? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor - font.pixelSize: textSize/1.4 - font.weight: Font.Medium + Repeater{ + model: ListModel { + id: siteModel + ListElement{siteName:"chn"; siteColour:"#508f00"} + ListElement{siteName:"lon"; siteColour:"#2b7ffc"} + ListElement{siteName:"mtl"; siteColour:"#979700"} + ListElement{siteName:"mum"; siteColour:"#ef9526"} + ListElement{siteName:"syd"; siteColour:"#008a46"} + } - bgDiv.opacity: enabled? 1.0 : 0.5 - forcedBgColorNormal: onDisk ? onDisk == 1? "transparent" - : Qt.darker(siteColour, 1) - : Qt.lighter(panelColor, 1.1) - - Rectangle{ - width: parent.width - height: parent.height - anchors.bottom: parent.bottom - visible: parent.onDisk && parent.onDisk == 1 - opacity: parent.enabled? 1.0 : 0.5 - z:-1 - // border.width: itemSpacing - // border.color: siteColour - gradient: Gradient { - GradientStop { position: 0.4; color: Qt.lighter(panelColor, 1.1) } - GradientStop { position: 0.8; color: Qt.darker(siteColour, 1) } - GradientStop { position: 1.0; color: Qt.darker(siteColour, 1) } - } - } + Rectangle{ + Layout.alignment: Qt.AlignTop + Layout.fillWidth: true + Layout.fillHeight: true + + // Layout.minimumHeight: siteDiv.height - 1 + // Layout.maximumHeight: siteDiv.height - 1 + // Layout.minimumWidth: (control.width - itemSpacing * 1.5 * (siteModel.count-1)) / siteModel.count + // Layout.maximumWidth: (control.width - itemSpacing * 1.5 * (siteModel.count-1)) / siteModel.count + + property int onDisk: { + if(index==0) onSiteChn + else if(index==1) onSiteLon + else if(index==2) onSiteMtl + else if(index==3) onSiteMum + else if(index==4) onSiteSyd + else false + } + + opacity: 0.5 + gradient: Gradient { + GradientStop { position: 0.4; + color: !onDisk ? siteDiv.panelColorLight : onDisk == 1 ? siteDiv.panelColorLight : siteColour + } + GradientStop { position: 0.8; + color: !onDisk ? siteDiv.panelColorLight : Qt.darker(siteColour, 1) + } + GradientStop { position: 1.0; + color: !onDisk ? siteDiv.panelColorLight : Qt.darker(siteColour, 1) + } } - } - ListModel{ - id: siteModel - ListElement{siteName:"chn"; siteColour:"#508f00"} - ListElement{siteName:"lon"; siteColour:"#2b7ffc"} - ListElement{siteName:"mtl"; siteColour:"#979700"} - ListElement{siteName:"mum"; siteColour:"#ef9526"} - ListElement{siteName:"syd"; siteColour:"#008a46"} - // ListElement{siteName:"van"; siteColour:"#7a1a39"} + XsLabel { + anchors.fill: parent + text: siteName + font.pixelSize: textSize/1.4 + font.weight: Font.Medium + } } - } } + + Item{ + Layout.fillWidth: true + Layout.fillHeight: true + } } } \ No newline at end of file diff --git a/src/plugin/data_source/dneg/shotbrowser/src/query_engine.cpp b/src/plugin/data_source/dneg/shotbrowser/src/query_engine.cpp index fe528ae84..b64bad63a 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/query_engine.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/query_engine.cpp @@ -416,6 +416,10 @@ void QueryEngine::merge_presets(nlohmann::json &destination, const nlohmann::jso group_ids.insert(i.value("id", utility::Uuid())); } + + auto add_to_end = R"([])"_json; + + for (auto it = destination.begin(); it != destination.end();) { if (it->value("type", "") == "group") { if (not group_ids.count(it->value("id", utility::Uuid())) and @@ -528,10 +532,8 @@ void QueryEngine::merge_presets(nlohmann::json &destination, const nlohmann::jso (*it)["userdata"] = "tree"; } - destination.push_back(*it); + add_to_end.push_back(*it); it = destination.erase(it); - - // it++; } } } else { @@ -561,6 +563,9 @@ void QueryEngine::merge_presets(nlohmann::json &destination, const nlohmann::jso it++; } } + + for(const auto &i: add_to_end) + destination.push_back(i); } catch (const std::exception &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); } @@ -1122,6 +1127,8 @@ utility::JsonStore QueryEngine::preprocess_terms( field = "sg_dneg_version"; else if (val == "Pipeline Status") field = "sg_status_list"; + else if (val == "Cut Order") + field = "entity.Shot.sg_cut_order"; } else if (entity == "Notes") { if (val == "Created") field = "created_at"; @@ -1572,6 +1579,10 @@ void QueryEngine::add_version_term_to_filter( qry->push_back(Number("sg_dneg_version").less_than(std::stoi(value))); } else if (term == "Newer Version") { qry->push_back(Number("sg_dneg_version").greater_than(std::stoi(value))); + } else if (term == "Cut Order Siblings") { + qry->push_back(Number("entity.Shot.sg_cut_order").between(std::stoi(value) - 20, std::stoi(value) + 20)); + } else if (term == "Shot Sequence") { + qry->push_back(RelationType("entity").in(get_sequence_shots(project_id, value, lookup))); } else if (term == "Id") { if (negated) qry->push_back(Number("id").is_not(std::stoi(value))); @@ -1756,17 +1767,31 @@ void QueryEngine::add_version_term_to_filter( try { auto seq = R"({"type": "Sequence", "id":0})"_json; auto seqs = std::vector(); - // if no match force failing query. or we'll get EVERYTHING - try { - seq["id"] = - resolve_query_value("Sequence", JsonStore(value), project_id, lookup) - .get(); - seqs.push_back(seq); - } catch (const std::exception &err) { - spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); - seqs.push_back(seq); + + if(value == "All") { + if(lookup.count(cache_name("Reference", project_id))) { + const auto &values = lookup.at(cache_name("Reference", project_id)); + for( const auto &i: values) { + seq["id"] = i.at("id"); + seqs.push_back(seq); + } + } + } else if (value == "Global") { + + } else { + // if no match force failing query. or we'll get EVERYTHING + try { + seq["id"] = + resolve_query_value("Sequence", JsonStore(value), project_id, lookup) + .get(); + seqs.push_back(seq); + } catch (const std::exception &err) { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); + seqs.push_back(seq); + } } - qry->push_back(RelationType("entity").in(seqs)); + if(not seqs.empty()) + qry->push_back(RelationType("entity").in(seqs)); } catch (const std::exception &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); throw XStudioError("Invalid query term " + term + " " + value); @@ -2274,7 +2299,7 @@ void QueryEngine::set_asset_type_cache( void QueryEngine::set_reference_cache( const std::string &key, const utility::JsonStore &data, utility::JsonStore &cache) { // auto tmp = R"([{"name":"", "id": 0}])"_json; - auto tmp = R"([{"name":"", "id": 0}])"_json; + auto tmp = R"([{"name":"", "id": 0}, {"name": "All", "id":1}, {"name": "Global", "id":2}])"_json; static const auto episode = json::json_pointer("/relationships/custom_entity20_sg_sequences_custom_entity20s/data"); @@ -2409,6 +2434,32 @@ JsonStore QueryEngine::data_from_field(const JsonStore &data) { return {result}; } +utility::JsonStore QueryEngine::get_sequence_shots( + const int project_id, const std::string &sequence, const utility::JsonStore &lookup) { + const auto key = cache_name("ShotSequence", project_id); + + auto result = JsonStore(R"([])"_json); + + if (lookup.count(key)) { + const auto &ss = lookup.at(key); + + auto shot_entity = R"({"type": "Shot", "id": 0})"_json; + + for(const auto & [shot_id, value]: ss.items()) { + for(const auto &s: value) { + if(s.at("name") == sequence) { + shot_entity["id"] = std::stoi(shot_id); + result.push_back(shot_entity); + break; + } + } + } + } + + return result; +} + + std::vector QueryEngine::get_sequence_name( const int project_id, const int shot_id, const utility::JsonStore &lookup) { auto result = std::vector(); @@ -2554,7 +2605,10 @@ utility::JsonStore QueryEngine::get_livelink_value( result = nlohmann::json("Shot-" + std::to_string(shot_id)); } } - + } else if (metadata.contains(json::json_pointer("/metadata/shotgun/shot")) and term == "Cut Order Siblings") { + result = nlohmann::json(std::to_string(metadata.at( + json::json_pointer( + "/metadata/shotgun/shot/attributes/sg_cut_order")).get())); } else if (metadata.contains(json::json_pointer("/metadata/shotgun/version"))) { @@ -2638,7 +2692,7 @@ utility::JsonStore QueryEngine::get_livelink_value( if (not asset_name.empty()) result = nlohmann::json(asset_name); } - } else if (term == "Sequence") { + } else if (term == "Sequence" || term == "Shot Sequence") { auto type = metadata.at( json::json_pointer( "/metadata/shotgun/version/relationships/entity/data/type")); diff --git a/src/plugin/data_source/dneg/shotbrowser/src/query_engine.hpp b/src/plugin/data_source/dneg/shotbrowser/src/query_engine.hpp index 79e2f6ec7..a60a22566 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/query_engine.hpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/query_engine.hpp @@ -154,6 +154,7 @@ const auto ValidTerms = R"_({ "Author", "Client Filename", "Completion Location", + "Cut Order Siblings", "Disable Global", "dnTag", "Entity", @@ -190,6 +191,7 @@ const auto ValidTerms = R"_({ "Sent To", "Sequence", "Shot Alternative", + "Shot Sequence", "Shot Status", "Shot Type", "Shot", @@ -254,7 +256,8 @@ const auto OrderByTermValues = R"([ {"name": "Version ASC"}, {"name": "Version DESC"}, {"name": "Pipeline Status ASC"}, - {"name": "Pipeline Status DESC"} + {"name": "Pipeline Status DESC"}, + {"name": "Cut Order ASC"} ])"_json; const auto BoolTermValues = R"([{"name": "True"},{"name": "False"}])"_json; @@ -335,6 +338,7 @@ const auto TermProperties = R"_({ "Author": { "negated": null, "livelink": false }, "Client Filename": { "negated": false, "livelink": null }, "Completion Location": { "negated": false, "livelink": null }, + "Cut Order Siblings": { "negated": null, "livelink": true }, "Department": { "negated": false, "livelink": null }, "Disable Global": { "negated": null, "livelink": null }, "dnTag": { "negated": false, "livelink": null }, @@ -380,6 +384,7 @@ const auto TermProperties = R"_({ "Sent To": { "negated": null, "livelink": null }, "Sequence": { "negated": false, "livelink": false }, "Shot Alternative": { "negated": null, "livelink": true }, + "Shot Sequence": { "negated": null, "livelink": false }, "Shot Status": { "negated": false, "livelink": null }, "Shot Type": { "negated": false, "livelink": false }, "Shot": { "negated": false, "livelink": false }, @@ -397,13 +402,14 @@ const auto TermProperties = R"_({ const std::set TermHasProjectKey = { "Asset", "asset", "Author", "episode", "Episode", "group", - "Group", "playlist", "Playlist", "Recipient", "sequence", "Sequence", + "Group", "playlist", "Playlist", "Recipient", "sequence", "Sequence", "shot sequence", "Shot Sequence", "shot", "Shot", "Shot Type", "Asset Type", "ShotSequence", "ShotSequenceList", "AssetList", "stage", "Stage", "unit", "Unit", "user", "User", "Reference", "reference"}; const std::set TermHasNoModel = { "Client Filename", + "Cut Order Siblings", "dnTag", "Entity", "Exclude Self", @@ -535,6 +541,9 @@ class QueryEngine { static std::vector get_sequence_name( const int project_id, const int shot_id, const utility::JsonStore &lookup); + static utility::JsonStore get_sequence_shots( + const int project_id, const std::string &sequence, const utility::JsonStore &lookup); + static std::vector get_asset_name(const int project_id, const int asset_id, const utility::JsonStore &lookup); diff --git a/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.cpp b/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.cpp index d59f421c2..0036c2ca6 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.cpp @@ -34,6 +34,7 @@ ShotBrowserResultModel::ShotBrowserResultModel(QObject *parent) : JSONTreeModel( "createdDateRole", "dateSubmittedToClientRole", "departmentRole", + "descriptionRole", "detailRole", "entityRole", "frameRangeRole", @@ -453,6 +454,7 @@ QVariant ShotBrowserResultModel::data(const QModelIndex &index, int role) const pipelineStatusFullRole, playlistTypeRole, subjectRole, + descriptionRole, versionNameRole}); for (auto r : troles) @@ -718,6 +720,10 @@ QVariant ShotBrowserResultModel::data(const QModelIndex &index, int role) const result = QVariantMapFromJson(j); break; + case Roles::descriptionRole: + result = QString::fromStdString(j.at("attributes").at("description").get()); + break; + case JSONTreeModel::Roles::JSONTextRole: result = QString::fromStdString(j.dump(2)); break; diff --git a/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.hpp b/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.hpp index 3f2bcbc0b..db4edc2cf 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.hpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.hpp @@ -69,6 +69,7 @@ class ShotBrowserResultModel : public JSONTreeModel { createdDateRole, dateSubmittedToClientRole, departmentRole, + descriptionRole, detailRole, entityRole, frameRangeRole, diff --git a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_request_ui.cpp b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_request_ui.cpp index 326e43f1e..444d678be 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_request_ui.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_request_ui.cpp @@ -547,6 +547,8 @@ QFuture ShotBrowserEngine::getPlaylistNotesFuture(const int id) { QFuture ShotBrowserEngine::getPlaylistVersionsFuture(const int id) { REQUEST_BEGIN() + + // std::this_thread::sleep_for(3s); scoped_actor sys{system()}; auto vers = request_receive_wait( diff --git a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.cpp b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.cpp index 70523c24d..361868469 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.cpp @@ -633,14 +633,36 @@ QFuture ShotBrowserEngine::remapCachePathFuture(const QPersistentModelInde return QtConcurrent::run([=]() { const auto orig = uri_to_posix_path(UriFromQUrl(path), false); static const auto pathre = std::regex(R"(^/jobs/(([^/]+)/.+)$)"); + static const auto sequencere = std::regex(R"(^/jobs/([^/]+)(/.+\.)(\{:\d*d\})(\..+)$)"); std::cmatch m; if (std::regex_match(orig.c_str(), m, pathre) and m[2] != site) { const auto npath = std::string("/jobs/") + site + "/" + std::string(m[1]); - // remap path to cache. + utility::add_remap_file_path(orig, npath); + // spdlog::warn("{} {}", orig, npath); + + std::cmatch ms; + if(std::regex_match(orig.c_str(), ms, sequencere)) { + const auto to_re = std::string("/jobs/") + ms[1].str() + ms[2].str() + "(\\d+)" + ms[4].str(); + const auto to_sub = std::string("/jobs/") + site + "/" + ms[1].str() + ms[2].str() + "$1" + ms[4].str(); + + const auto from_re = std::string("/jobs/") + site + "/" + ms[1].str() + ms[2].str() + "(\\d+)" + ms[4].str(); + const auto from_sub = std::string("/jobs/") + ms[1].str() + ms[2].str() + "$1" + ms[4].str(); + + // spdlog::warn("{}", to_re); + // spdlog::warn("{}", to_sub); + // spdlog::warn("{}", from_re); + // spdlog::warn("{}", from_sub); + + utility::add_regex_mapping( + std::make_pair(std::regex(to_re), to_sub), + std::make_pair(std::regex(from_re), from_sub) + ); + } + // trigger remote loading of source by daemon. auto socket = new QUdpSocket(); QByteArray data; @@ -655,6 +677,7 @@ QFuture ShotBrowserEngine::remapCachePathFuture(const QPersistentModelInde // switch current media source ? anon_mail(media::current_media_source_atom_v, source_uuid).send(media_actor); + anon_mail(media::invalidate_cache_atom_v).delay(1s).send(media_actor); return QUrlFromUri(posix_path_to_uri(npath, false, true)); } @@ -663,7 +686,6 @@ QFuture ShotBrowserEngine::remapCachePathFuture(const QPersistentModelInde }); } - QFuture ShotBrowserEngine::requestFileTransferFuture( const QVariantList &qitems, const QString &project, @@ -805,6 +827,42 @@ ShotBrowserEngine::getIvyVersionFuture(const QString &project, const QUuid &stal } +QFuture ShotBrowserEngine::getReferenceStemsFuture(const int project_id) { + return QtConcurrent::run([=]() { + // run shotgun query to get data.. + auto result = R"([])"_json; + + try { + scoped_actor sys{system()}; + auto episode_filter = + FilterBy().And( + Number("project.Project.id").is(project_id), + Text("code").is("REFERENCE")); + + auto jsn = request_receive( + *sys, backend_, shotgun_entity_search_atom_v, + "CustomEntity20", + JsonStore(episode_filter), + std::vector({"sg_sequences", "id","code"}), + std::vector({"id"}), + 1, + 1 + ); + const auto ptr = json::json_pointer("/data/0/relationships/sg_sequences/data"); + + if(jsn.contains(ptr)) + result =jsn.at(ptr); + + + } catch (const std::exception &err) { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); + } + + return mapFromValue(result); + }); +} + + QFuture ShotBrowserEngine::getSequencePathFuture( const QStringList &preferred, const QString &project, const QUuid &stalk) { return QtConcurrent::run([=]() { diff --git a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.hpp b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.hpp index fd5cd2358..d08aa0520 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.hpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.hpp @@ -187,6 +187,9 @@ class ShotBrowserEngine : public QMLActor { QString getEpisodes(const int project_id) { return getEpisodesFuture(project_id).result(); } QFuture getEpisodesFuture(const int project_id); + QVariant getReferenceStems(const int project_id) {return getReferenceStemsFuture(project_id).result();} + QFuture getReferenceStemsFuture(const int project_id); + QString getUsers(const int project_id = -1) { return getUsersFuture(project_id).result(); } QFuture getUsersFuture(const int project_id = -1); diff --git a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_plugin.cpp b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_plugin.cpp index 424c84df1..079731f03 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_plugin.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_plugin.cpp @@ -41,7 +41,7 @@ ShotBrowser::ShotBrowser(caf::actor_config &cfg, const utility::JsonStore &init_ // print_on_exit(this, "MediaHookActor"); secret_source_ = actor_cast(this); - shotgun_ = spawn(); + shotgun_ = spawn(CPPHTTPLIB_CONNECTION_TIMEOUT_SECOND, 60, 60); link_to(shotgun_); // we need to recieve authentication updates. @@ -1066,10 +1066,8 @@ void ShotBrowser::update_preferences(const JsonStore &js) { // we ignore after initial setup.. if (engine().system_presets().at("children").empty()) { - // auto project_presets = preference_value( - // js, "/plugin/data_source/shotbrowser/project_presets"); + auto site_presets = JsonStore(R"([])"_json); - // preference_value(js, "/plugin/data_source/shotbrowser/site_presets"); auto preset_paths = preference_value(js, "/plugin/data_source/shotbrowser/preset_paths"); @@ -1122,25 +1120,26 @@ void ShotBrowser::update_preferences(const JsonStore &js) { "Failed to Read {} {} {}", __PRETTY_FUNCTION__, path, err.what()); } } else { - user_presets = preference_value( - js, "/plugin/data_source/shotbrowser/user_presets"); - - auto uorp = preference_overridden_path( - js, "/plugin/data_source/shotbrowser/user_presets"); - if (ends_with(uorp, "application-v2.json")) { - - auto prefs = GlobalStoreHelper(system()); - prefs.set_overridden_path( - replace_once(uorp, "/application-v2.json", "/plugin-v2.json"), - "/plugin/data_source/shotbrowser/user_presets", - false); - prefs.save("PLUGIN"); - } - - // force save as we're using the new file now.. - anon_mail(json_store::sync_atom_v, true) - .delay(1s) - .send(caf::actor_cast(this)); + // user_presets = preference_value( + // js, "/plugin/data_source/shotbrowser/user_presets"); + + // auto uorp = preference_overridden_path( + // js, "/plugin/data_source/shotbrowser/user_presets"); + + // if (ends_with(uorp, "application-v2.json")) { + + // auto prefs = GlobalStoreHelper(system()); + // prefs.set_overridden_path( + // replace_once(uorp, "/application-v2.json", "/plugin-v2.json"), + // "/plugin/data_source/shotbrowser/user_presets", + // false); + // prefs.save("PLUGIN"); + // } + + // // force save as we're using the new file now.. + // anon_mail(json_store::sync_atom_v, true) + // .delay(1s) + // .send(caf::actor_cast(this)); } } @@ -1947,7 +1946,57 @@ void ShotBrowser::do_add_media_sources_from_ivy( // to acquire MediaDetail) to the MediaActor - we only call it // when we've fully 'built' each MediaSourceActor in our 'sources' // list -0 see the request/then handler below where it is used + auto find_source = [](const std::vector &prefs, const std::vector> + &names) -> std::string { + auto name = std::string(); + + for (const auto &pref : prefs) { + for (const auto &i : names) { + if (i.second == pref) { + name = i.second; + break; + } + } + if (not name.empty()) + break; + } + + // do partial match + for (const auto &pref : prefs) { + for (const auto &i : names) { + if (std::string::npos != i.second.find(pref)) { + name = i.second; + break; + } + } + if (not name.empty()) + break; + } + + if (name.empty()) { + for (const auto &i : names) { + if (i.second == "movie_dneg") { + name = i.second; + break; + } + } + } + + if (name.empty()) { + for (const auto &i : names) { + if (i.second == "SG Movie") { + name = i.second; + break; + } + } + } + + return name; + }; + + auto finalise = [=]() { + mail(media::add_media_source_atom_v, *good_sources) .request(ivy_media_task_data->media_actor_, infinite) .then( @@ -1961,37 +2010,7 @@ void ShotBrowser::do_add_media_sources_from_ivy( .then( [=](const std::vector> &names) { - auto name = std::string(); - - for (const auto &pref : - ivy_media_task_data->preferred_visual_sources_) { - for (const auto &i : names) { - if (i.second == pref) { - name = pref; - break; - } - } - if (not name.empty()) - break; - } - - if (name.empty()) { - for (const auto &i : names) { - if (i.second == "movie_dneg") { - name = i.second; - break; - } - } - } - - if (name.empty()) { - for (const auto &i : names) { - if (i.second == "SG Movie") { - name = i.second; - break; - } - } - } + auto name = find_source(ivy_media_task_data->preferred_visual_sources_, names); if (not name.empty()) anon_mail( @@ -2010,37 +2029,7 @@ void ShotBrowser::do_add_media_sources_from_ivy( .then( [=](const std::vector> &names) { - auto name = std::string(); - - for (const auto &pref : - ivy_media_task_data->preferred_audio_sources_) { - for (const auto &i : names) { - if (i.second == pref) { - name = pref; - break; - } - } - if (not name.empty()) - break; - } - - if (name.empty()) { - for (const auto &i : names) { - if (i.second == "movie_dneg") { - name = i.second; - break; - } - } - } - - if (name.empty()) { - for (const auto &i : names) { - if (i.second == "SG Movie") { - name = i.second; - break; - } - } - } + auto name = find_source(ivy_media_task_data->preferred_audio_sources_, names); if (not name.empty()) anon_mail( @@ -2053,6 +2042,7 @@ void ShotBrowser::do_add_media_sources_from_ivy( [=](error &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, to_string(err)); }); + continue_processing_job_queue(); }, [=](error &err) { @@ -2079,6 +2069,7 @@ void ShotBrowser::do_add_media_sources_from_ivy( .request(ivy, infinite) .then( [=](const utility::UuidActorVector &sources) { + // we want to make sure the 'MediaDetail' has been fetched on the // sources before adding to the parent MediaActor - this means we // don't build up a massive queue of IO heavy MediaDetail fetches diff --git a/src/plugin/hud/pixel_probe/src/pixel_probe.cpp b/src/plugin/hud/pixel_probe/src/pixel_probe.cpp index 1026a497d..5d803f07c 100644 --- a/src/plugin/hud/pixel_probe/src/pixel_probe.cpp +++ b/src/plugin/hud/pixel_probe/src/pixel_probe.cpp @@ -201,59 +201,67 @@ void PixelProbeHUD::update_onscreen_info( colour_pipeline = get_colour_pipeline_actor(viewport_name); } - if (onscreen_image_set && visible() && colour_pipeline) { - - Imath::V4f pointer(pointer_position.x, pointer_position.y, 0.0, 1.0f); - bool ptr_in_image = false; - // loop over on-screen images - for (int i = 0; i < onscreen_image_set->num_onscreen_images(); ++i) { - const auto &im = onscreen_image_set->onscreen_image(i); - if (im) { - - if (im != current_image_) { - current_image_ = im; - pixel_unpack_uniforms_ = im->shader_params(); - pixel_unpack_uniforms_.merge(im.colour_pipe_uniforms()); - } - - // Convert pointer_position to normalised image coordinates - // (image width always spans -1.0, 1.0 in x) - const float a = 1.0f / image_aspect(im); - Imath::V4f p = pointer * im.layout_transform().inverse(); - Imath::V2f p0(p.x / p.w, p.y / p.w); - if (p0.x >= -1.0f && p0.x <= 1.0f && p0.y >= -a && p0.y <= a) { - - // pointer is inside image boundary - Imath::V2i image_coord( - int(round((p0.x + 1.0f) * 0.5f * im->image_size_in_pixels().x)), - int(round((p0.y / a + 1.0f) * 0.5f * im->image_size_in_pixels().y))); + auto pick_image_pixel = [=](const media_reader::ImageBufPtr &im, const Imath::V4f &pointer) -> bool { + if (!im) return false; + utility::JsonStore pixel_unpack_uniforms = im->shader_params(); + pixel_unpack_uniforms.merge(im.colour_pipe_uniforms()); + + // Convert pointer_position to normalised image coordinates + // (image width always spans -1.0, 1.0 in x) + const float a = 1.0f / image_aspect(im); + Imath::V4f p = pointer * im.layout_transform().inverse(); + Imath::V2f p0(p.x / p.w, p.y / p.w); + if (p0.x >= -1.0f && p0.x <= 1.0f && p0.y >= -a && p0.y <= a) { + + // pointer is inside image boundary + Imath::V2i image_coord( + int(round((p0.x + 1.0f) * 0.5f * im->image_size_in_pixels().x)), + int(round((p0.y / a + 1.0f) * 0.5f * im->image_size_in_pixels().y))); + + + // here we get the RGB, YUV value at the image coordinate + const auto pixel_info = im->pixel_info(image_coord, pixel_unpack_uniforms); + + if (!viewport_name.empty()) { + // update our attribute that tracks which viewport the + // mouse pointer is in - used to reveal/hide the pixel + // info overlay per viewport + pixel_info_current_viewport_->set_value(viewport_name); + } + // we send the pixel info to the colour pipeline to add it's own colourspace + // transforms and info, if it needs/wants to + mail(colour_pipeline::pixel_info_atom_v, pixel_info, im.frame_id()) + .request(colour_pipeline, infinite) + .then( + [=](const media_reader::PixelInfo &extended_info) mutable { + make_pixel_info_onscreen_text(extended_info); + }, + [=](caf::error &err) { + + }); + return true; + } + return false; + }; - // here we get the RGB, YUV value at the image coordinate - const auto pixel_info = im->pixel_info(image_coord, pixel_unpack_uniforms_); - ptr_in_image = true; + if (onscreen_image_set && visible() && colour_pipeline) { - if (!viewport_name.empty()) { - // update our attribute that tracks which viewport the - // mouse pointer is in - used to reveal/hide the pixel - // info overlay per viewport - pixel_info_current_viewport_->set_value(viewport_name); - } + Imath::V4f pointer(pointer_position.x, pointer_position.y, 0.0, 1.0f); - // we send the pixel info to the colour pipeline to add it's own colourspace - // transforms and info, if it needs/wants to - mail(colour_pipeline::pixel_info_atom_v, pixel_info, im.frame_id()) - .request(colour_pipeline, infinite) - .then( - [=](const media_reader::PixelInfo &extended_info) mutable { - make_pixel_info_onscreen_text(extended_info); - }, - [=](caf::error &err) { - - }); + // use hero image first, so in A/B mode for example we pick the pixel for + // the on-screen image. + bool ptr_in_image = pick_image_pixel(onscreen_image_set->hero_image(), pointer); + // If pointer is not over 'hero' image (e.g. grid compare mode) then + // we loop over all images to try and find one where the pointer is + // hovered... + if (!ptr_in_image) { + for (int i = 0; i < onscreen_image_set->num_onscreen_images(); ++i) { + if (pick_image_pixel(onscreen_image_set->onscreen_image(i), pointer)) { + ptr_in_image = true; break; } - } + } } if (!ptr_in_image) { // empty info diff --git a/src/plugin/hud/pixel_probe/src/pixel_probe.hpp b/src/plugin/hud/pixel_probe/src/pixel_probe.hpp index 5f751b172..ade719aeb 100644 --- a/src/plugin/hud/pixel_probe/src/pixel_probe.hpp +++ b/src/plugin/hud/pixel_probe/src/pixel_probe.hpp @@ -65,8 +65,6 @@ class PixelProbeHUD : public plugin::HUDPluginBase { bool is_enabled_ = {false}; - media_reader::ImageBufPtr current_image_; - utility::JsonStore pixel_unpack_uniforms_; }; } // namespace xstudio::ui::viewport diff --git a/src/plugin/media_hook/dneg/dnhook/src/dneg.cpp b/src/plugin/media_hook/dneg/dnhook/src/dneg.cpp index 684bd99ca..ebafbcf40 100644 --- a/src/plugin/media_hook/dneg/dnhook/src/dneg.cpp +++ b/src/plugin/media_hook/dneg/dnhook/src/dneg.cpp @@ -656,7 +656,10 @@ class DNegMediaHook : public MediaHook { r["input_view"] = "Film"; } } else if (input_category == "edit_ref" || input_category == "movie_media") { - if (is_cms1_config) { + if ( context["SHOW"] == "SWD" && is_cms1_config) { + r["input_colorspace"] = "Client_HDR-ST2100"; + } + else if (is_cms1_config) { // If Client view is not available on the show, fallback to DNEG for // linearisation r["input_colorspace"] = "Client_Graded_Rec709:Client_Rec709:DNEG_Rec709"; @@ -688,7 +691,7 @@ class DNegMediaHook : public MediaHook { // * regular scene-linear workflow media // * external display-linear workflow media if (has_untonemapped_view) { - if (input_category == "edit_ref" || input_category == "movie_media") { + if (context["SHOW"] != "SWD" && (input_category == "edit_ref" || input_category == "movie_media")) { r["untonemapped_colorspace"] = "disp_Rec709-G24"; r["untonemapped_view"] = "Un-tone-mapped"; } else if ( diff --git a/src/plugin/media_metadata/openexr/src/openexr.cpp b/src/plugin/media_metadata/openexr/src/openexr.cpp index 54af67573..419811abd 100644 --- a/src/plugin/media_metadata/openexr/src/openexr.cpp +++ b/src/plugin/media_metadata/openexr/src/openexr.cpp @@ -440,8 +440,18 @@ void to_json_tile_description_value(nlohmann::json &root, const T *value) { bool dump_json_headers(const Imf::Header &h, nlohmann::json &root) { for (Imf::Header::ConstIterator i = h.begin(); i != h.end(); ++i) { try { - if (auto ta = dynamic_cast(&i.attribute())) - to_json_value(root[i.name()], ta); + if (auto ta = dynamic_cast(&i.attribute())) { + if (ta->value().size() > 2048) { + // exr metadata fields are not size limited. Some pipelines are using + // this to store large amounts of data in the exr file. It's unlikely + // such data will be usable in xSTUDIO and it tends to severely bloat + // the json blobs that store metadata. + root[i.name()]["type"] = ta->typeName(); + root[i.name()]["value"] = ""; + } else { + to_json_value(root[i.name()], ta); + } + } else if (auto ta = dynamic_cast(&i.attribute())) to_json_value(root[i.name()], ta); diff --git a/src/plugin/media_reader/openexr/src/openexr.cpp b/src/plugin/media_reader/openexr/src/openexr.cpp index b660d0653..d9ed4cb10 100644 --- a/src/plugin/media_reader/openexr/src/openexr.cpp +++ b/src/plugin/media_reader/openexr/src/openexr.cpp @@ -290,24 +290,15 @@ ImageBufPtr OpenEXRMediaReader::image(const media::AVFrameID &mptr) { // compute the size of the buffer we need const size_t n_pixels = (data_window.size().x + 1) * (data_window.size().y + 1); - const size_t bytes_per_channel_r = - (pix_type[0] == -1 ? 0 - : pix_type[0] == Imf::PixelType::HALF ? 2 - : 4); - const size_t bytes_per_channel_g = - (pix_type[1] == -1 ? 0 - : pix_type[1] == Imf::PixelType::HALF ? 2 - : 4); - const size_t bytes_per_channel_b = - (pix_type[2] == -1 ? 0 - : pix_type[2] == Imf::PixelType::HALF ? 2 - : 4); - const size_t bytes_per_channel_a = - (pix_type[3] == -1 ? 0 - : pix_type[3] == Imf::PixelType::HALF ? 2 - : 4); - const size_t bytes_per_pixel = - bytes_per_channel_r + bytes_per_channel_g + bytes_per_channel_b + bytes_per_channel_a; + + size_t bytes_per_pixel = 0; + for (size_t i = 0; i < exr_channels_to_load.size(); ++i) { + if (pix_type[i] == Imf::PixelType::HALF) + bytes_per_pixel += 2; + else if (static_cast(pix_type[i]) != -1) // uint32 or float32 + bytes_per_pixel += 4; + } + const size_t buf_size = n_pixels * bytes_per_pixel; // const size_t gl_line_size = 8192*4; diff --git a/src/plugin/media_reader/openexr/src/openexr_metadata.cpp b/src/plugin/media_reader/openexr/src/openexr_metadata.cpp index 007eb9db1..b046eb034 100644 --- a/src/plugin/media_reader/openexr/src/openexr_metadata.cpp +++ b/src/plugin/media_reader/openexr/src/openexr_metadata.cpp @@ -365,8 +365,20 @@ void to_json_tile_description_value(nlohmann::json &root, const T *value) { bool dump_json_headers(const Imf::Header &h, nlohmann::json &root) { for (Imf::Header::ConstIterator i = h.begin(); i != h.end(); ++i) { try { - if (auto ta = dynamic_cast(&i.attribute())) - to_json_value(root[i.name()], ta); + + if (auto ta = dynamic_cast(&i.attribute())) { + + if (ta->value().size() > 2048) { + // exr metadata fields are not size limited. Some pipelines are using + // this to store large amounts of data in the exr file. It's unlikely + // such data will be usable in xSTUDIO and it tends to severely bloat + // the json blobs that store metadata. + root[i.name()]["type"] = ta->typeName(); + root[i.name()]["value"] = ""; + } else { + to_json_value(root[i.name()], ta); + } + } else if (auto ta = dynamic_cast(&i.attribute())) to_json_value(root[i.name()], ta); diff --git a/src/plugin/python_plugins/annotations_exporter/annotations_exporter.py b/src/plugin/python_plugins/annotations_exporter/annotations_exporter.py index 26912dee8..845416910 100644 --- a/src/plugin/python_plugins/annotations_exporter/annotations_exporter.py +++ b/src/plugin/python_plugins/annotations_exporter/annotations_exporter.py @@ -37,7 +37,7 @@ class ExportType(Enum): MEDIA_NAMED_IMAGES = 1 - USER_NAMED_IMAEGS = 2 + USER_NAMED_IMAGES = 2 GREASEPENCIL = 3 # Declare our plugin class - we're using the PluginBase base class here. @@ -144,6 +144,26 @@ def __init__(self, connection): "annotation_export_attrs" ) + self.cancelled = self.add_attribute( + "Cancelled", + False + ) + self.cancelled.expose_in_ui_attrs_group( + "annotation_export_attrs" + ) + + self.progress_message = self.add_attribute( + "Progress", + { + "progress": 0, + "message" : "No export in progress.", + "in_progress": False + } + ) + self.progress_message.expose_in_ui_attrs_group( + "annotation_export_attrs" + ) + self.__output_folder = None self.__export_type = None self.subfolder = None @@ -175,6 +195,14 @@ def menu_callback(self): # auto set the output name self.attribute_changed(self.scope, 0) + self.progress_message.set_value( + { + "progress": 0, + "message": "", + "in_progress": False + } + ) + # This pops up our export dialog self.create_qml_item( dialog_qml @@ -184,6 +212,7 @@ def do_export(self, scope, export_type, user_name, output_folder, file_type, res self.__output_folder = urlsplit(output_folder).path self.exported_images = [] + self.cancelled.set_value(False) # This is not necessary, the attributes should already by set in the UI # code but we'll do it here to make sure the backend attributes are @@ -208,7 +237,7 @@ def do_export(self, scope, export_type, user_name, output_folder, file_type, res if self.export_type.value() == "Annotated Images - Use Media Name and Frame": self.__export_type = ExportType.MEDIA_NAMED_IMAGES elif self.export_type.value() == "Annotated Images - Use Your Own Name": - self.__export_type = ExportType.USER_NAMED_IMAEGS + self.__export_type = ExportType.USER_NAMED_IMAGES elif self.export_type.value() == "Greasepencil Package": self.__export_type = ExportType.GREASEPENCIL # try to make package folder @@ -231,7 +260,8 @@ def do_export(self, scope, export_type, user_name, output_folder, file_type, res elif scope == "Current Media": self.export_media_annotations( - self.current_playhead().on_screen_media + self.current_playhead().on_screen_media, + check=True ) elif scope == "Current Playlist / Timeline": @@ -241,12 +271,40 @@ def do_export(self, scope, export_type, user_name, output_folder, file_type, res ) else: + # "Entire Session" + count = 0 for playlist in self.connection.api.session.playlists: - self.export_bookmark_for_current_playlist( - playlist + for media in playlist.media: + count += len(media.ordered_bookmarks()) + if count == 0: + self.popup_message_box( + "Error", + "It doesn't look like there are any bookmarks in the session to export.") + return + index = 0 + for playlist in self.connection.api.session.playlists: + index = self.export_bookmark_for_current_playlist( + playlist, + count=count, + index=index ) + + if self.cancelled.value(): + return False + + if not self.exported_images: + return ("No images were exported. Please check that there are annotations on the media in the selected scope.") + + self.progress_message.set_value( + { + "progress": 100, + "message": "Exporting complete", + "in_progress": False + } + ) + # generate the greasePencil.xml if self.__export_type == ExportType.GREASEPENCIL: @@ -283,11 +341,11 @@ def export_frame(self, idx, frame, duration, bookmark, media): frame, self.__image_file_ext ) - elif self.__export_type == ExportType.USER_NAMED_IMAEGS: - path = "{}/{}_{}.{}".format( + elif self.__export_type == ExportType.USER_NAMED_IMAGES: + path = "{}/{}.{:04d}.{}".format( self.__output_folder, self.user_name.value(), - idx, + idx+1, self.__image_file_ext ) elif self.__export_type == ExportType.GREASEPENCIL: @@ -335,7 +393,7 @@ def export_bookmark_on_current_frame(self): bookmark, m) - def export_media_annotations(self, media, index = 0): + def export_media_annotations(self, media, index = 0, check = False, count = -1): bookmarks = media.ordered_bookmarks() export_frames = set() @@ -344,7 +402,8 @@ def export_media_annotations(self, media, index = 0): bookmark_frame = bookmark_frame + media.media_source().media_reference.timecode().total_frames() duration = max(1, int(round(bookmark.duration.total_seconds()/media.media_source().rate.seconds()))) export_frames.add((bookmark, bookmark_frame, duration)) - if not export_frames: + + if check and not export_frames: self.popup_message_box( "Error", "It doesn't look like there are any bookmarks on the current media to export.") @@ -352,6 +411,17 @@ def export_media_annotations(self, media, index = 0): for (bookmark, frame, duration) in export_frames: + if self.cancelled.value(): + return index + + if count > 0: + self.progress_message.set_value( + { + "progress": (index*100)/count, + "message": "Exporting {} of {} annotated images...".format(index, count), + "in_progress": True + } + ) index = self.export_frame( index, frame, @@ -361,13 +431,24 @@ def export_media_annotations(self, media, index = 0): return index - def export_bookmark_for_current_playlist(self, playlist, index = 0): + def export_bookmark_for_current_playlist(self, playlist, index = 0, count = -1): + + if count == -1: + count = 0 + for media in playlist.media: + count += len(media.ordered_bookmarks()) + if not count: + self.popup_message_box( + "Error", + "It doesn't look like there are any bookmarks on the current playlist to export.") + return media_items = playlist.media for m in media_items: index = self.export_media_annotations( m, - index + index, + count=count ) return index diff --git a/src/plugin/python_plugins/annotations_exporter/qml/AnnotationsExporter.1/AnnotationsExportDialog.qml b/src/plugin/python_plugins/annotations_exporter/qml/AnnotationsExporter.1/AnnotationsExportDialog.qml index 31b5bc56a..c6752b80e 100644 --- a/src/plugin/python_plugins/annotations_exporter/qml/AnnotationsExporter.1/AnnotationsExportDialog.qml +++ b/src/plugin/python_plugins/annotations_exporter/qml/AnnotationsExporter.1/AnnotationsExportDialog.qml @@ -2,6 +2,8 @@ import QtQuick import QtQuick.Layouts import QtQuick.Dialogs +import QuickFuture 1.0 +import QuickPromise 1.0 import xstudio.qml.models 1.0 import xStudio 1.0 @@ -43,6 +45,16 @@ XsWindow { attributeTitle: "Resolution" model: plugin_attrs } + XsAttributeValue { + id: progress + attributeTitle: "Progress" + model: plugin_attrs + } + XsAttributeValue { + id: cancelled + attributeTitle: "Cancelled" + model: plugin_attrs + } ColumnLayout { @@ -236,6 +248,27 @@ XsWindow { } } + XsText { + text: "Status" + Layout.alignment: Qt.AlignRight + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 24 + Rectangle { + height: 24 + width: progress.value.progress != undefined ? progress.value.progress * (parent.width)/100 : 0 + color: XsStyleSheet.accentColor + } + XsText { + height: 24 + verticalAlignment: Text.AlignVCenter + text: progress.value.message + } + } + + } @@ -258,8 +291,8 @@ XsWindow { text: qsTr("Cancel") width: XsStyleSheet.primaryButtonStdWidth*2 onClicked: { + cancelled.value = true dialog.hide() - dialog.destroy() } } @@ -268,29 +301,38 @@ XsWindow { Layout.leftMargin: 5 text: qsTr("Export") width: XsStyleSheet.primaryButtonStdWidth*2 - //enabled: outputFile.text != "" + enabled: !progress.value.in_progress onClicked: { - - var return_val = python_callback( - "do_export", - scopeChoice.currentText, - exportMode.currentText, - outputName.text, - outputFolder.text, - exportFileType.currentText, - resolution_choice.currentText - ) - if (Array.isArray(return_val)) { - // do export should return [True, message] - dialogHelpers.messageDialogFunc("Annotations Export", return_val[1], "Ok") - if (return_val[0] == true) { - dialog.visible = false - } - } else { - // report (likely) error of some sort - dialogHelpers.errorDialogFunc("Annotations Export", return_val) + + var py_args = { + "scope": scopeChoice.currentText, + "export_type": exportMode.currentText, + "user_name": outputName.text, + "output_folder": outputFolder.text, + "file_type": exportFileType.currentText, + "resolution": resolution_choice.currentText } + Future.promise( + helpers.pythonAsyncCallback("AnnotationsExporter", "do_export", py_args) + ).then(function(return_val) { + + if (Array.isArray(return_val)) { + // do export should return [True, message] + dialogHelpers.messageDialogFunc("Annotations Export", return_val[1], "Ok") + if (return_val[0] == true) { + dialog.visible = false + } + } else if (typeof return_val === "string") { + // report (likely) error of some sort + dialogHelpers.errorDialogFunc("Annotations Export", return_val) + } + + }, + function(__rr) { + dialogHelpers.errorDialogFunc("Annotations Export", __rr) + }) + } } } diff --git a/src/plugin/python_plugins/utility/utility_py_plugin.py b/src/plugin/python_plugins/utility/utility_py_plugin.py index 10872c2de..a2db9ac09 100644 --- a/src/plugin/python_plugins/utility/utility_py_plugin.py +++ b/src/plugin/python_plugins/utility/utility_py_plugin.py @@ -36,7 +36,7 @@ def __init__(self, connection): self.flip_flop_menu_id = self.insert_menu_item( menu_model_name="media_list_menu_", - menu_text="Set Flip/Flop", + menu_text="Flip/Flop", menu_path="Media Settings", menu_item_position=1.0, attr_id=self.flip_flop_attr.uuid, @@ -44,7 +44,7 @@ def __init__(self, connection): self.set_submenu_position( menu_model_name="media_list_menu_", - submenu_path="Media Settings|Set Flip/Flop", + submenu_path="Media Settings|Flip/Flop", menu_item_position=3.9) self.connect_to_ui() diff --git a/src/plugin/utility/filesystem_browser/CMakeLists.txt b/src/plugin/utility/filesystem_browser/CMakeLists.txt index 68422d357..8b3a4c780 100644 --- a/src/plugin/utility/filesystem_browser/CMakeLists.txt +++ b/src/plugin/utility/filesystem_browser/CMakeLists.txt @@ -51,6 +51,7 @@ file(GLOB QML_SOURCES RELATIVE ${CMAKE_CURRENT_LIST_DIR} FileSystemBrowser/xstud qt_add_qml_module(${PROJECT_NAME} URI FileSystemBrowser + OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/FileSystemBrowser VERSION 1.0 NO_GENERATE_PLUGIN_SOURCE NO_PLUGIN_OPTIONAL diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTreeBody.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTreeBody.qml index 5adffd306..e9f19cd33 100644 --- a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTreeBody.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTreeBody.qml @@ -516,7 +516,7 @@ Item { id: scanButton Layout.preferredWidth: 60 Layout.fillHeight: true - Layout.margins: 5 + Layout.margins: 2 property var hovered: mma.containsMouse property var pressed: mma.pressed radius: 4 @@ -534,8 +534,8 @@ Item { hoverEnabled: true acceptedButtons: Qt.LeftButton onClicked: { + wasClicked = true; sendCommand({"action": "force_scan", "path": model.path}) - wasClicked = true } } visible: isHovered && !model.isLoading diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbGroup.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbGroup.qml index 18acd33e1..8b409f0f2 100644 --- a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbGroup.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbGroup.qml @@ -14,7 +14,7 @@ Item { property var thumbsModelIndex //Layout.preferredHeight: num_thumbnail_rows*130 + header.height + 10 - implicitHeight: Math.ceil(file_count/scanResultsModel.numThumbnailCols)*120 + header.height + implicitHeight: Math.ceil(file_count/scanResultsModel.numThumbnailCols)*thumbHeight + header.height property bool strictlyVisible: (y < thumbFlickable.windowTop) && (y+height) > (thumbFlickable.windowBottom) property bool visibleInFlickable: smallResultSet || ((y < thumbFlickable.windowTopMore) && (y+height) > (thumbFlickable.windowBottomMore)) @@ -53,29 +53,35 @@ Item { rootIndex: thumbsModelIndex//visibleInFlickable ? thumbsModelIndex : undefined delegate: FSThumbItem { rooty: thumbGroup.y + header.height - width: 160 - height: 120 + width: thumbWidth + height: thumbHeight } } + property int underMouseRow: -1 + Connections { target: mouseArea enabled: thumbGroup.strictlyVisible function onPositionChanged(mouse) { var pt = mouseArea.mapToItem(thumbGroup, mouse.x, mouse.y) - var row = Math.floor((pt.y-40)/120) - var col = Math.floor(pt.x/160) - var index = scanResultsModel.index(row*scanResultsModel.numThumbnailCols + col, 0, thumbsModelIndex) - if (index.valid) { - var item = thumbRepeater.itemAt(index.row) - if (item) { - underMouseIndex = index - return true + var row = Math.floor((pt.y-header.height)/thumbHeight) + var col = Math.floor(pt.x/thumbWidth) + let ur = row*scanResultsModel.numThumbnailCols + col + if (ur != underMouseRow) { + underMouseRow = ur + var index = scanResultsModel.index(ur, 0, thumbsModelIndex) + if (index.valid) { + var item = thumbRepeater.itemAt(index.row) + if (item) { + underMouseIndex = index + return + } } + underMouseRow = -1 } - if (pt.y > 0 && pt.y < header.height && pt.x > 0 && pt.x < header.width) { + if (pt.y > 0 && pt.y < header.height && pt.x > 0 && pt.x < header.width && underMouseIndex != thumbsModelIndex) { underMouseIndex = thumbsModelIndex - return true } } function onPressed() { diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbView.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbView.qml index 07731ca9c..879812670 100644 --- a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbView.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbView.qml @@ -34,6 +34,9 @@ Flickable { property var yMin: 0 property var smallResultSet: scanResultsModel.numMediaFiles < 2000 + property var thumbWidth: 160 + property var thumbHeight: 120 + // When the user is scrolling we delay update of the windowBottom/windowTop // properties until scrolling has stopped for 200ms, to avoid excessive // thumbnail loading/unloading during scroll diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FileSystemBrowser.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FileSystemBrowser.qml index ccb3537dc..6b01e3c61 100644 --- a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FileSystemBrowser.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FileSystemBrowser.qml @@ -757,17 +757,24 @@ Item { // Nothing found message ColumnLayout { - anchors.centerIn: parent + anchors.margins: 30 + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter spacing: 20 visible: scanResultsModel.numMediaFiles === 0 && !searching_attr.value && !deepScan XsText { + id: msg + Layout.fillWidth: true text: "No media found in current folder." color: "#666666" font.pixelSize: 18 + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter } XsPrimaryButton { - Layout.preferredWidth: 200 + Layout.preferredWidth: Math.min(parent.width, 200) Layout.alignment: Qt.AlignHCenter visible: scanResultsModel.numRootFolders && !deepScan text: "Run Full Scan" diff --git a/src/plugin/utility/session_snapshots/src/qml/SessionSnapshots.1/SessionSnapshotsMenu.qml b/src/plugin/utility/session_snapshots/src/qml/SessionSnapshots.1/SessionSnapshotsMenu.qml index 4d0d947cb..450e93cfe 100644 --- a/src/plugin/utility/session_snapshots/src/qml/SessionSnapshots.1/SessionSnapshotsMenu.qml +++ b/src/plugin/utility/session_snapshots/src/qml/SessionSnapshots.1/SessionSnapshotsMenu.qml @@ -55,9 +55,8 @@ XsMenuItemNew { doRescan() } - XsModelProperty { + XsPreference { id: __snapshot_paths - role: "valueRole" index: globalStoreModel.searchRecursive("/core/snapshot/paths", "pathRole") } property alias snapshot_paths: __snapshot_paths.value diff --git a/src/plugin/utility/session_snapshots/src/snapshot_model_ui.cpp b/src/plugin/utility/session_snapshots/src/snapshot_model_ui.cpp index ab2dac255..e15081765 100644 --- a/src/plugin/utility/session_snapshots/src/snapshot_model_ui.cpp +++ b/src/plugin/utility/session_snapshots/src/snapshot_model_ui.cpp @@ -193,11 +193,11 @@ utility::JsonStore SnapshotMenuModel::toMenuModelItemData(FileSystemItem *item) menu_item_data["children"].push_back(save_selected_snapshot_item); - utility::JsonStore options_menu_item; - options_menu_item["name"] = "Options"; - options_menu_item["menu_item_type"] = "menu"; - options_menu_item["children"] = nlohmann::json::array(); - options_menu_item["watch_visibility"] = false; + // utility::JsonStore options_menu_item; + // options_menu_item["name"] = "Options"; + // options_menu_item["menu_item_type"] = "menu"; + // options_menu_item["children"] = nlohmann::json::array(); + // options_menu_item["watch_visibility"] = false; utility::JsonStore create_folder_item; create_folder_item["name"] = "Create New Folder ..."; @@ -206,7 +206,7 @@ utility::JsonStore SnapshotMenuModel::toMenuModelItemData(FileSystemItem *item) create_folder_item["snapshot_filesystem_path"] = snapshot_filesystem_path; create_folder_item["watch_visibility"] = false; create_folder_item["user_data"] = "SNAPSHOT_NEW_FOLDER"; - options_menu_item["children"].push_back(create_folder_item); + menu_item_data["children"].push_back(create_folder_item); utility::JsonStore reveal_folder_item; reveal_folder_item["name"] = "Reveal On Disk ..."; @@ -215,9 +215,9 @@ utility::JsonStore SnapshotMenuModel::toMenuModelItemData(FileSystemItem *item) reveal_folder_item["snapshot_filesystem_path"] = snapshot_filesystem_path; reveal_folder_item["watch_visibility"] = false; reveal_folder_item["user_data"] = "SNAPSHOT_REVEAL"; - options_menu_item["children"].push_back(reveal_folder_item); + menu_item_data["children"].push_back(reveal_folder_item); - menu_item_data["children"].push_back(options_menu_item); + // menu_item_data["children"].push_back(options_menu_item); } // the first snapshot menus have a 'remove' option. We know we are @@ -266,10 +266,32 @@ void SnapshotMenuModel::setPaths(const QVariant &value) { try { if (not value.isNull()) { - paths_ = value; + auto jsn = mapFromValue(value); + if (jsn.is_array()) { + auto it = jsn.begin(); + + while (it != jsn.end()) { + try { + auto uri = caf::make_uri(it->at("path").get()); + if(uri) { + auto path = uri_to_posix_path(*uri); + if(not fs::exists(path)) { + spdlog::info("Remove missing snapshot {} {}", it->at("name").get(), path); + it = jsn.erase(it); + } + else + it++; + } else + it++; + } catch(...) { + it++; + } + } + } + + paths_ = mapFromValue(jsn); emit pathsChanged(); - auto jsn = mapFromValue(paths_); if (jsn.is_array()) { items_.clear(); @@ -302,7 +324,7 @@ bool SnapshotMenuModel::createFolder(const QModelIndex &index, const QString &na auto new_path = fs::path(path) / StdFromQString(name); try { fs::create_directory(new_path); - rescan(index.parent().parent()); + rescan(index.parent()); } catch (const std::exception &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); } diff --git a/src/plugin/video_output/bmd_decklink/src/CMakeLists.txt b/src/plugin/video_output/bmd_decklink/src/CMakeLists.txt index 772b71fc0..0fc95b50d 100644 --- a/src/plugin/video_output/bmd_decklink/src/CMakeLists.txt +++ b/src/plugin/video_output/bmd_decklink/src/CMakeLists.txt @@ -74,6 +74,7 @@ file(GLOB QML_SOURCES RELATIVE ${CMAKE_CURRENT_LIST_DIR} BlackmagicSDI/*.qml) qt_add_qml_module(${PROJECT_NAME} URI BlackmagicSDI + OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/BlackmagicSDI VERSION 1.0 NO_GENERATE_PLUGIN_SOURCE NO_PLUGIN_OPTIONAL diff --git a/src/plugin/video_output/video_renderer/src/qml/VideoRenderer.1/VideoRendererDialog.qml b/src/plugin/video_output/video_renderer/src/qml/VideoRenderer.1/VideoRendererDialog.qml index 0a1ddd888..7e9bf2777 100644 --- a/src/plugin/video_output/video_renderer/src/qml/VideoRenderer.1/VideoRendererDialog.qml +++ b/src/plugin/video_output/video_renderer/src/qml/VideoRenderer.1/VideoRendererDialog.qml @@ -239,6 +239,13 @@ XsWindow { } property alias load_on_completion: __load_on_completion.value + XsAttributeValue { + id: __include_annotations + attributeTitle: "include_annotations" + model: video_render_attrs + } + property alias include_annotations: __include_annotations.value + XsAttributeValue { id: __frame_rates attributeTitle: "frame_rates" @@ -543,6 +550,21 @@ XsWindow { } } + XsText { + text: "Include Annotations" + Layout.alignment: Qt.AlignRight + } + + XsCheckBox { + + Layout.alignment: Qt.AlignLeft + Layout.preferredHeight: widgetHeight + checked: include_annotations == true + onClicked: { + include_annotations = !include_annotations + } + } + VidRenderDlgDivider { Layout.preferredHeight: widgetHeight Layout.topMargin: 20 diff --git a/src/plugin/video_output/video_renderer/src/video_render_plugin.cpp b/src/plugin/video_output/video_renderer/src/video_render_plugin.cpp index a6b9c1b1d..4ab9bbb46 100644 --- a/src/plugin/video_output/video_renderer/src/video_render_plugin.cpp +++ b/src/plugin/video_output/video_renderer/src/video_render_plugin.cpp @@ -80,6 +80,11 @@ VideoRenderPlugin::VideoRenderPlugin( render_audio->expose_in_ui_attrs_group("video_render_attrs"); render_audio->set_preference_path("/plugin/video_render/render_audio"); + include_annotations_ = + add_boolean_attribute("include_annotations", "include_annotations", false); + include_annotations_->expose_in_ui_attrs_group("video_render_attrs"); + include_annotations_->set_preference_path("/plugin/video_render/include_annotations"); + auto frame_rate = add_string_attribute("frame_rate", "frame_rate", "24.0"); frame_rate->expose_in_ui_attrs_group("video_render_attrs"); frame_rate->set_preference_path("/plugin/video_render/frame_rate"); @@ -155,7 +160,14 @@ caf::message_handler VideoRenderPlugin::message_handler_extensions() { auto p = queued_jobs_.begin(); current_worker_ = *p; queued_jobs_.erase(p); + // pin annotation visibility on the offscreen viewport + // for this job before it starts rendering + update_annotations_visibility_override(); mail(utility::user_start_action_atom_v).send(current_worker_.actor()); + } else { + // no job running - lift the annotation visibility + // override from the offscreen viewport + update_annotations_visibility_override(); } if (overall_status_->value() != "Error") { if (current_worker_) @@ -170,6 +182,19 @@ caf::message_handler VideoRenderPlugin::message_handler_extensions() { make_offscreen_viewport(rp); return rp; }, + + [=](session::render_to_video_atom, + const utility::Uuid &job_id, + const bool cancel) -> result { + + if (cancel) { + remove_job(job_id); + return true; + } + return false; + + }, + [=](session::render_to_video_atom, const std::string &render_item_name, const utility::Uuid &parent_playlist_item_id, @@ -216,6 +241,7 @@ caf::message_handler VideoRenderPlugin::message_handler_extensions() { ocio_view, auto_check_output, timecode); + rp.deliver(result); } catch (std::exception &e) { @@ -229,6 +255,12 @@ caf::message_handler VideoRenderPlugin::message_handler_extensions() { [=](broadcast::join_broadcast_atom atom, caf::actor subscriber) { return mail(atom, subscriber).delegate(event_group_); }, + [=](colour_pipeline::colour_pipeline_atom, const utility::Uuid &container_to_be_rendered_uuid) { + // this message handler lets us set-up the OCIO display and view + // dropdowns to show the appropriate options for the container ( + // playlist, timeline, contact sheet) with the given uuid. + update_ocio_choices(container_to_be_rendered_uuid); + }, [=](utility::event_atom, const utility::JsonStore &status, const std::string &ffmpeg_output) { @@ -441,7 +473,7 @@ void VideoRenderPlugin::make_offscreen_viewport(caf::typed_response_promise(studio_ui_registry); // tell the studio actor to create an offscreen viewport. - mail(offscreen_viewport_atom_v, "vid_render_offscreen_viewport", false) + mail(offscreen_viewport_atom_v, OFFSCREEN_VIEWPORT_NAME, false) .request(studio_ui, infinite) .then( [=](caf::actor offscreen_vp) mutable { @@ -453,6 +485,7 @@ void VideoRenderPlugin::make_offscreen_viewport(caf::typed_response_promise("ANNOTATIONS_CORE_PLUGIN"); + if (!annotations_plugin) + return; + + std::string action = "CLEAR_VISIBILITY_OVERRIDE"; + if (current_worker_) { + auto p = job_include_annotations_.find(current_worker_.uuid()); + action = (p != job_include_annotations_.end() && p->second) + ? "FORCE_SHOW_ANNOTATIONS" + : "FORCE_HIDE_ANNOTATIONS"; + } + + anon_mail( + ui::viewport::annotation_atom_v, + ui::viewport::viewport_atom_v, + OFFSCREEN_VIEWPORT_NAME, + action) + .send(annotations_plugin); +} + utility::Uuid VideoRenderPlugin::create_render_job( const std::string &render_item_name, const utility::Uuid &parent_playlist_item_id, @@ -758,6 +819,11 @@ utility::Uuid VideoRenderPlugin::create_render_job( // make a worker actor that executes the rendering and sends status // updates to the plugin actor (i.e. this class) utility::Uuid job_id = utility::Uuid::generate(); + + // capture the "include annotations" setting at submit time so queued jobs + // are not affected by later changes to the checkbox + job_include_annotations_[job_id] = + include_annotations_ && include_annotations_->value(); auto worker = spawn( job_id, render_item_name, @@ -788,7 +854,8 @@ utility::Uuid VideoRenderPlugin::create_render_job( // handler for worker exit (worker self exists when its render has // completed or is cancelled) - monitor(worker, [this, worker](const error &err) { + monitor(worker, [this, worker, job_id](const error &err) { + job_include_annotations_.erase(job_id); if (current_worker_ == worker) { current_worker_ = utility::UuidActor(); anon_mail(utility::user_start_action_atom_v) diff --git a/src/plugin/video_output/video_renderer/src/video_render_plugin.hpp b/src/plugin/video_output/video_renderer/src/video_render_plugin.hpp index d26eff604..1acc80df3 100644 --- a/src/plugin/video_output/video_renderer/src/video_render_plugin.hpp +++ b/src/plugin/video_output/video_renderer/src/video_render_plugin.hpp @@ -16,6 +16,10 @@ class VideoRenderPlugin : public plugin::StandardPlugin { inline static const utility::Uuid PLUGIN_UUID = utility::Uuid("4147f82d-1006-4025-ac04-79c81cd5e7b7"); + // name of the offscreen viewport this plugin renders through (created + // for us by the StudioUI actor) + inline static const std::string OFFSCREEN_VIEWPORT_NAME{"vid_render_offscreen_viewport"}; + void menu_item_activated( const utility::JsonStore &menu_item_data, const std::string &user_data) override; @@ -26,6 +30,7 @@ class VideoRenderPlugin : public plugin::StandardPlugin { private: void remove_job(const utility::Uuid &job_id); + void update_annotations_visibility_override(); void update_ocio_choices(const utility::Uuid &target_render_item_id); void make_offscreen_viewport(caf::typed_response_promise rp); void playback_render_output( @@ -64,6 +69,9 @@ class VideoRenderPlugin : public plugin::StandardPlugin { module::StringAttribute *ocio_warnings_; std::map jobs_status_data_; module::StringAttribute *overall_status_; + module::BooleanAttribute *include_annotations_{nullptr}; + // per-job capture of the "include annotations" setting at submit time + std::map job_include_annotations_; utility::UuidActorVector queued_jobs_; utility::UuidActor current_worker_; utility::JsonStore ocio_settings_; diff --git a/src/plugin/video_output/video_renderer/src/video_render_worker.cpp b/src/plugin/video_output/video_renderer/src/video_render_worker.cpp index 4ba21b360..43b3f70d3 100644 --- a/src/plugin/video_output/video_renderer/src/video_render_worker.cpp +++ b/src/plugin/video_output/video_renderer/src/video_render_worker.cpp @@ -588,6 +588,8 @@ void VideoRenderWorker::start_render_task() { soundcard_sample_rate_) / 1000000; + audio_stream_position_ = playhead_position_; + // next step ... start FFMpeg start_ffmpeg_process(); } @@ -738,15 +740,12 @@ void VideoRenderWorker::start_ffmpeg_process() { #else - auto tmpdir = utility::get_env("TMPDIR"); - if (!tmpdir) { - throw std::runtime_error("Failed to read $TMPDIR env var."); - } + // system temp directory for the ffmpeg FIFOs - honours $TMPDIR when set, + // falls back to /tmp otherwise (TMPDIR is typically unset on Linux) + const auto tmpdir = std::filesystem::temp_directory_path(); output_yuv_filename_ = fmt::format( - "{}/xstdio_render_image_pipe_{}.yuv", - std::string(*tmpdir), - to_string(job_uuid_)); // utility::temp_file("test.yuv"); + "{}/xstudio_render_image_pipe_{}.yuv", tmpdir.string(), to_string(job_uuid_)); if (mkfifo(output_yuv_filename_.c_str(), 0666) == -1) { throw std::runtime_error( fmt::format( @@ -758,7 +757,7 @@ void VideoRenderWorker::start_ffmpeg_process() { } if (!audio_codec_opts_.empty()) { output_audio_filename_ = fmt::format( - "{}/xsutdio_render_audio_pipe_{}.raw", std::string(*tmpdir), to_string(job_uuid_)); + "{}/xstudio_render_audio_pipe_{}.raw", tmpdir.string(), to_string(job_uuid_)); if (mkfifo(output_audio_filename_.c_str(), 0666) == -1) { throw std::runtime_error( fmt::format( diff --git a/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.cpp b/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.cpp index fd4a637b6..7169244fa 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.cpp +++ b/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.cpp @@ -169,11 +169,13 @@ AnnotationsRenderer::AnnotationsRenderer( std::atomic_bool &cursor_blink, std::atomic_bool &hide_all, std::atomic_int *hide_strokes, - std::atomic_bool *hide_all2) + std::atomic_bool *hide_all2, + std::atomic_int *visibility_override) : viewport_name_(std::move(viewport_name)), cursor_blink_(cursor_blink), hide_all_(hide_all), hide_strokes_(hide_strokes), + visibility_override_(visibility_override), hide_per_viewport_(hide_all2) { canvas_renderer_.reset(new ui::opengl::OpenGLCanvasRenderer()); texthandle_renderer_.reset(new CaptionHandleRenderer()); @@ -186,7 +188,12 @@ void AnnotationsRenderer::render_image_overlay( const float device_pixel_ratio, const xstudio::media_reader::ImageBufPtr &frame) { - if (hide_all_ || *hide_per_viewport_) + // per-viewport override of the global Visibility toggle (see + // AnnotationsVisibilityOverride) + const int vis_override = visibility_override_ ? visibility_override_->load() : VO_DEFAULT; + if (vis_override == VO_FORCE_HIDE) + return; + if ((hide_all_ && vis_override != VO_FORCE_SHOW) || *hide_per_viewport_) return; // 'live' annotation edit data (strokes & shapes under construction) is @@ -207,8 +214,16 @@ void AnnotationsRenderer::render_image_overlay( auto my_annotation = dynamic_cast(bookmark->annotation_.get()); bool hide_strokes = false; - if (*hide_strokes_ == 2 && live_canvas_data && + if (*hide_strokes_ == 1) { + // DONT_RENDER_STROKES: the video-stream encoder wants NO strokes on + // its viewport - the web client renders every committed stroke as + // vectors instead, so hide them all here (live and laser strokes are + // already suppressed by the same flag elsewhere). + hide_strokes = true; + } else if ( + *hide_strokes_ == 2 && live_canvas_data && live_canvas_data->skip_annotation_uuid() == bookmark->detail_.uuid_) { + // DONT_RENDER_LIVE_STROKES: hide only the annotation being edited. hide_strokes = true; } if (my_annotation && live_canvas_data) { @@ -302,9 +317,18 @@ void AnnotationsRenderer::render_viewport_overlay( const float viewport_du_dpixel, const float device_pixel_ratio) { - // Laser strokes are intentionally drawn regardless of the Display Mode - // (Always / Only When Paused) and the user-visibility toggles — they - // are transient pointing aids and must always be visible while drawn. + // Laser strokes are transient pointing aids: they are drawn regardless of + // the Display Mode (Always / Only When Paused) and the user-visibility + // toggles, so they stay visible on the local viewport while drawn. + // + // The one exception is the stroke-suppression flag the video-stream + // encoder sets on its viewport (DONT_RENDER_STROKES / + // DONT_RENDER_LIVE_STROKES). Laser strokes ARE live strokes that the web + // client renders itself (the sync plugin forwards laser_stroke_atom + // updates), so baking them into the stream too would draw them twice on + // the client. Honour that flag here, exactly as render_image_overlay does. + if (hide_strokes_ && *hide_strokes_ != 0) + return; if (on_screen_frames) { diff --git a/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.hpp b/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.hpp index e29025c73..f6ca9c980 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.hpp +++ b/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.hpp @@ -41,7 +41,8 @@ class AnnotationsRenderer : public plugin::ViewportOverlayRenderer { std::atomic_bool &cursor_blink, std::atomic_bool &hide_all, std::atomic_int *hide_strokes, - std::atomic_bool *hide_all2); + std::atomic_bool *hide_all2, + std::atomic_int *visibility_override); void render_image_overlay( const Imath::M44f &transform_window_to_viewport_space, @@ -68,6 +69,7 @@ class AnnotationsRenderer : public plugin::ViewportOverlayRenderer { std::atomic_bool &cursor_blink_; std::atomic_bool &hide_all_; std::atomic_int *hide_strokes_; + std::atomic_int *visibility_override_; std::atomic_bool *hide_per_viewport_; }; diff --git a/src/plugin/viewport_overlay/annotations/src/annotation_render_data.hpp b/src/plugin/viewport_overlay/annotations/src/annotation_render_data.hpp index 2c5cbd408..fc0369c68 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotation_render_data.hpp +++ b/src/plugin/viewport_overlay/annotations/src/annotation_render_data.hpp @@ -7,6 +7,14 @@ namespace xstudio::ui::viewport { +// Per-viewport override of the global annotations Visibility toggle. Stored +// as an int in a per-viewport atomic; set via the FORCE_SHOW_ANNOTATIONS / +// FORCE_HIDE_ANNOTATIONS / CLEAR_VISIBILITY_OVERRIDE actions on the +// AnnotationsCore (annotation_atom, viewport_atom, viewport_name, action) +// message handler. VO_FORCE_SHOW bypasses the global hide for that viewport, +// VO_FORCE_HIDE always hides, VO_DEFAULT keeps the global behaviour. +enum AnnotationsVisibilityOverride { VO_DEFAULT = 0, VO_FORCE_SHOW = 1, VO_FORCE_HIDE = 2 }; + enum class HandleHoverState { NotHovered, HoveredInCaptionArea, @@ -30,6 +38,10 @@ class LaserStrokesRenderDataSet : public utility::BlindDataObject { } } + void add_laser_stroke(const std::shared_ptr &laser_stroke) { + laser_strokes_.emplace_back(std::make_shared(*laser_stroke)); + } + const StrokeVec &laser_strokes() const { return laser_strokes_; } private: diff --git a/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.cpp b/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.cpp index 46e11a0a1..c5a24d4b2 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.cpp +++ b/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.cpp @@ -117,6 +117,11 @@ caf::message_handler AnnotationsCore::message_handler_extensions() { hide_strokes_per_viewport_[viewport_name] = new std::atomic_int(0); } *(hide_strokes_per_viewport_[viewport_name]) = 1; + // The stream viewport may already have rendered its (single, + // while paused) frame before this message arrived - seed the + // frame-change anchor now so clients get the current frame's + // snapshot instead of waiting for the next frame change. + maybe_broadcast_streamed_frame(viewport_name, false); } else if (action == "DO_RENDER_STROKES") { if (hide_strokes_per_viewport_.find(viewport_name) == hide_strokes_per_viewport_.end()) { @@ -129,6 +134,28 @@ caf::message_handler AnnotationsCore::message_handler_extensions() { hide_strokes_per_viewport_[viewport_name] = new std::atomic_int(0); } *(hide_strokes_per_viewport_[viewport_name]) = 2; + } else if ( + action == "FORCE_SHOW_ANNOTATIONS" || action == "FORCE_HIDE_ANNOTATIONS" || + action == "CLEAR_VISIBILITY_OVERRIDE") { + // per-viewport override of the global annotations Visibility + // toggle - lets a consumer that owns a viewport (e.g. an + // offscreen render/export viewport) pin annotation visibility + // regardless of the (session-shared) Visibility attribute. + if (visibility_override_per_viewport_.find(viewport_name) == + visibility_override_per_viewport_.end()) { + visibility_override_per_viewport_[viewport_name] = + new std::atomic_int(VO_DEFAULT); + } + *(visibility_override_per_viewport_[viewport_name]) = + action == "FORCE_SHOW_ANNOTATIONS" ? VO_FORCE_SHOW + : action == "FORCE_HIDE_ANNOTATIONS" ? VO_FORCE_HIDE + : VO_DEFAULT; + } else if (action == "BROADCAST_ANNOTATIONS") { + // A sync client joined or reconnected: broadcast a fresh + // authoritative frame-scope snapshot (with the layout table) + // so it doesn't depend on the sync plugin's cached copy. + broadcast_committed_annotation( + AnnotationBasePtr(), utility::Uuid(), media_reader::ImageBufPtr()); } }, [=](utility::event_atom, @@ -188,6 +215,9 @@ void AnnotationsCore::receive_annotation_data(const utility::JsonStore &d) { } else if (event == "PaintEnd") { if (user_edit_data->item_type == Canvas::ItemType::Laser) { broadcast_live_laser_stroke(user_id, true); + // the stroke is now complete (mouse released): move it into the + // fading set, which is what starts the fade. + commit_laser_stroke(user_edit_data); } else { broadcast_live_stroke(user_edit_data, user_id, true); push_live_edit_to_bookmark(user_edit_data); @@ -214,8 +244,9 @@ void AnnotationsCore::receive_annotation_data(const utility::JsonStore &d) { } else if (event == "ToolChanged") { clear_live_caption(user_edit_data); if (user_edit_data->item_type == Canvas::ItemType::Laser && - !user_edit_data->laser_strokes.empty()) { + user_edit_data->live_laser_stroke) { broadcast_live_laser_stroke(user_id, true); + commit_laser_stroke(user_edit_data); user_edit_data->item_type = Canvas::ItemType::None; } } else if (event == "PaintUndo") { @@ -357,7 +388,7 @@ void AnnotationsCore::start_stroke_or_shape( auto c = payload["paint"]["rgba"].get>(); utility::ColourTriplet colour(c[0], c[1], c[2]); - user_edit_data->laser_strokes.emplace_back( + user_edit_data->live_laser_stroke.reset( Stroke::Brush(colour, size, 0.0f, c[3], 0.0f, 1.0f)); user_edit_data->item_type = Canvas::ItemType::Laser; @@ -373,12 +404,10 @@ void AnnotationsCore::start_stroke_or_shape( if (user_edit_data->live_stroke && payload.contains("id")) { user_edit_data->live_stroke->set_id(payload["id"].get()); - } else if ( - user_edit_data->item_type == Canvas::ItemType::Laser && - !user_edit_data->laser_strokes.empty()) { + } else if (user_edit_data->live_laser_stroke) { const std::string id = payload.contains("id") ? payload["id"].get() : to_string(utility::Uuid::generate()); - user_edit_data->laser_strokes.back()->set_id(id); + user_edit_data->live_laser_stroke->set_id(id); } } @@ -453,8 +482,8 @@ void AnnotationsCore::modify_stroke_or_shape( } else if (user_edit_data->item_type == Canvas::ItemType::Laser) { - if (!user_edit_data->laser_strokes.empty()) { - user_edit_data->laser_strokes.back()->add_points(points); + if (user_edit_data->live_laser_stroke) { + user_edit_data->live_laser_stroke->add_points(points); } } } @@ -1009,9 +1038,13 @@ void AnnotationsCore::pick_image_to_annotate( // that we're about to start adding a stroke to so it can send annotations // data to web clients (so that they can render the annotation locally) if (user_edit_data->edited_bookmark_id.is_null()) { - annotation_about_to_be_edited(annotation_to_add_to, next_bookmark_uuid_); + annotation_about_to_be_edited( + annotation_to_add_to, next_bookmark_uuid_, user_edit_data->annotated_image); } else { - annotation_about_to_be_edited(annotation_to_add_to, user_edit_data->edited_bookmark_id); + annotation_about_to_be_edited( + annotation_to_add_to, + user_edit_data->edited_bookmark_id, + user_edit_data->annotated_image); } } @@ -1068,6 +1101,14 @@ utility::BlindDataObjectPtr AnnotationsCore::onscreen_render_data( for (const auto &p : live_edit_data_) { const auto &user_edit_data = p.second; + // the in-progress stroke (full opacity) ... + if (user_edit_data->live_laser_stroke) { + if (!data) { + data = new LaserStrokesRenderDataSet(); + } + data->add_laser_stroke(user_edit_data->live_laser_stroke); + } + // ... and the committed strokes that are fading out. if (!user_edit_data->laser_strokes.empty()) { if (!data) { data = new LaserStrokesRenderDataSet(); @@ -1086,7 +1127,9 @@ utility::BlindDataObjectPtr AnnotationsCore::onscreen_render_data( const bool is_hero_image, const bool images_are_in_grid_layout) const { - if (hide_all_drawings_) + const int vis_override = visibility_override(viewport_name); + if (vis_override == VO_FORCE_HIDE || + (hide_all_drawings_ && vis_override != VO_FORCE_SHOW)) return utility::BlindDataObjectPtr(); PerImageAnnotationRenderDataSet *data = nullptr; @@ -1157,6 +1200,8 @@ void AnnotationsCore::images_going_on_screen( viewport_current_images_[viewport_name] = images; + maybe_broadcast_streamed_frame(viewport_name, playhead_playing); + if (hide_all_per_viewport_.find(viewport_name) == hide_all_per_viewport_.end()) { hide_all_per_viewport_[viewport_name] = new std::atomic_bool(false); } @@ -1169,11 +1214,11 @@ void AnnotationsCore::images_going_on_screen( bool images_went_off_the_screen = false; auto p = live_edit_data_.begin(); while (p != live_edit_data_.end()) { - // Keep entries that still own fading laser strokes — they are - // viewport overlays unrelated to the annotated image, and must - // outlive frame changes until fully faded. + // Keep entries that still own an in-progress or fading laser stroke — + // they are viewport overlays unrelated to the annotated image, and + // must outlive frame changes until fully faded. if (p->second->viewport_name == viewport_name && - p->second->item_type != Canvas::ItemType::Laser && + !p->second->live_laser_stroke && p->second->laser_strokes.empty()) { bool still_on_screen = false; for (int i = 0; i < images->num_onscreen_images(); ++i) { @@ -1234,12 +1279,18 @@ AnnotationsCore::make_overlay_renderer(const std::string &viewport_name) { hide_all_per_viewport_[viewport_name] = new std::atomic_bool(false); } + if (visibility_override_per_viewport_.find(viewport_name) == + visibility_override_per_viewport_.end()) { + visibility_override_per_viewport_[viewport_name] = new std::atomic_int(VO_DEFAULT); + } + return plugin::ViewportOverlayRendererPtr(new AnnotationsRenderer( viewport_name, cursor_blink_, hide_all_drawings_, hide_strokes_per_viewport_[viewport_name], - hide_all_per_viewport_[viewport_name])); + hide_all_per_viewport_[viewport_name], + visibility_override_per_viewport_[viewport_name])); } AnnotationBasePtr AnnotationsCore::build_annotation(const utility::JsonStore &anno_data) { @@ -1264,12 +1315,9 @@ void AnnotationsCore::undo(LiveEditData &user_edit_data) { AnnotationBasePtr modified_annotation(mod_annotation); update_bookmark_annotation(bookmark_for_undo_id, modified_annotation, false); - if (current_edited_annotation_uuid_ != bookmark_for_undo_id) { - annotation_about_to_be_edited(modified_annotation, bookmark_for_undo_id); - } else { - mail(utility::event_atom_v, annotation_data_atom_v, modified_annotation) - .send(live_edit_event_group_); - } + current_edited_annotation_uuid_ = bookmark_for_undo_id; + broadcast_committed_annotation( + modified_annotation, bookmark_for_undo_id, user_edit_data->annotated_image); } else { @@ -1290,12 +1338,9 @@ void AnnotationsCore::redo(LiveEditData &user_edit_data) { AnnotationBasePtr modified_annotation(mod_annotation); update_bookmark_annotation(bookmark_for_undo_id, modified_annotation, false); - if (current_edited_annotation_uuid_ != bookmark_for_undo_id) { - annotation_about_to_be_edited(modified_annotation, bookmark_for_undo_id); - } else { - mail(utility::event_atom_v, annotation_data_atom_v, modified_annotation) - .send(live_edit_event_group_); - } + current_edited_annotation_uuid_ = bookmark_for_undo_id; + broadcast_committed_annotation( + modified_annotation, bookmark_for_undo_id, user_edit_data->annotated_image); } else { @@ -1314,12 +1359,18 @@ void AnnotationsCore::broadcast_live_stroke( AnnotationBasePtr anno_ptr(anno); + int image_index = 0; + std::vector layout_table; + streaming_grid_layout(user_edit_data->annotated_image, image_index, layout_table); + mail( utility::event_atom_v, annotation_data_atom_v, anno_ptr, user_id, - stroke_completed) + stroke_completed, + image_index, + layout_table) .send(live_edit_event_group_); if (draw_events_event_group_) { @@ -1358,12 +1409,11 @@ void AnnotationsCore::broadcast_live_laser_stroke( if (p == live_edit_data_.end()) return; const auto &user_edit_data = p->second; - if (user_edit_data->item_type != Canvas::ItemType::Laser || - user_edit_data->laser_strokes.empty()) + if (!user_edit_data->live_laser_stroke) return; Annotation *anno = new Annotation(); - anno->canvas().append_item(*(user_edit_data->laser_strokes.back())); + anno->canvas().append_item(*(user_edit_data->live_laser_stroke)); mail( utility::event_atom_v, @@ -1374,6 +1424,203 @@ void AnnotationsCore::broadcast_live_laser_stroke( .send(live_edit_event_group_); } +const media_reader::ImageBufDisplaySetPtr *AnnotationsCore::streaming_images() const { + + // Prefer the streaming (offscreen) viewport - the surface the web client + // overlays - identified by the stroke-suppression flag the encoder set on + // it. Its per-cell layout matches any viewport in the same compare mode. + for (const auto &hs : hide_strokes_per_viewport_) { + if (hs.second && hs.second->load() != 0) { + auto it = viewport_current_images_.find(hs.first); + if (it != viewport_current_images_.end() && it->second && + it->second->num_onscreen_images() > 0) { + return &it->second; + } + } + } + // No fallback to other viewports: their grid layout transforms depend on + // THEIR aspect, so a snapshot gathered from the main viewport carries a + // slightly different table than one from the stream viewport - clients + // re-scale every stroke when the two alternate. Better no table (callers + // reuse the last one sent) than a wrong one. + return nullptr; +} + +void AnnotationsCore::streaming_grid_layout( + const media_reader::ImageBufPtr &annotated_image, + int &image_index, + std::vector &layout_table) const { + + image_index = 0; + layout_table.clear(); + + const media_reader::ImageBufDisplaySetPtr *images = streaming_images(); + if (!images) { + // Stream viewport unknown right now: reuse the last table broadcast + // for it rather than sending none - an empty table makes clients fall + // back to identity and momentarily re-scale every stroke. + layout_table = last_streamed_layout_table_; + return; + } + + const int n = (*images)->num_onscreen_images(); + for (int i = 0; i < n; ++i) { + const auto &im = (*images)->onscreen_image(i); + layout_table.push_back(im.layout_transform()); + if (annotated_image && + im.frame_id().key() == annotated_image.frame_id().key()) { + image_index = i; + } + } +} + +void AnnotationsCore::maybe_broadcast_streamed_frame( + const std::string &viewport_name, const bool playhead_playing) { + + // Streaming viewport (the one the encoder flagged for stroke suppression): + // the stream carries no strokes, so the web clients depend entirely on our + // broadcasts. Called from images_going_on_screen AND from the + // DONT_RENDER_STROKES handler: while paused the stream viewport renders + // its first frame exactly once, and that render can beat the (async) + // DONT_RENDER_STROKES message - without the second call site the anchor + // would stay silent until the first frame change, leaving joining clients + // with no strokes. + // Best-effort by design: this runs inside the playhead's show handler, so + // an escaped exception would kill the plugin actor and abort xSTUDIO. A + // missed broadcast only delays the web update until the next anchor. + try { + auto hs = hide_strokes_per_viewport_.find(viewport_name); + if (hs == hide_strokes_per_viewport_.end() || !hs->second || + hs->second->load() == 0) + return; + auto imit = viewport_current_images_.find(viewport_name); + if (imit == viewport_current_images_.end() || !imit->second) + return; + const auto &images = imit->second; + + std::vector layout_table; + std::vector frame_keys; + layout_table.reserve(images->num_onscreen_images()); + frame_keys.reserve(images->num_onscreen_images()); + for (int i = 0; i < images->num_onscreen_images(); ++i) { + layout_table.push_back(images->onscreen_image(i).layout_transform()); + frame_keys.push_back(to_string(images->onscreen_image(i).frame_id().key())); + } + + if (playhead_playing) { + // No frame anchors during playback (clients hide strokes while + // playing); clearing the keys makes the first paused frame + // broadcast its snapshot. + last_streamed_frame_keys_.clear(); + } else if (!frame_keys.empty() && frame_keys != last_streamed_frame_keys_) { + // FRAME-CHANGE ANCHOR: the streamed frame changed - broadcast + // the new frame's full snapshot (which carries the layout + // table), flagged so clients apply it in sync with the + // playout-delayed video instead of immediately. + last_streamed_frame_keys_ = frame_keys; + if (!layout_table.empty()) + last_streamed_layout_table_ = layout_table; + broadcast_committed_annotation( + AnnotationBasePtr(), + utility::Uuid(), + media_reader::ImageBufPtr(), + /*frame_changed=*/true); + } else if (!layout_table.empty() && layout_table != last_streamed_layout_table_) { + // Same frame, new layout (compare-mode/grid switch): a + // layout-only update re-maps the clients' stored strokes + // without touching the stroke sets. + last_streamed_layout_table_ = layout_table; + mail(utility::event_atom_v, annotation_data_atom_v, layout_table) + .send(live_edit_event_group_); + } + } catch (const std::exception &e) { + spdlog::warn( + "{} streamed-frame broadcast failed: {}", __PRETTY_FUNCTION__, e.what()); + } +} + +void AnnotationsCore::broadcast_committed_annotation( + const bookmark::AnnotationBasePtr &anno, + const utility::Uuid &bookmark_uuid, + const media_reader::ImageBufPtr &annotated_image, + const bool frame_changed) { + + // Best-effort: called from paint/undo/show handlers - an escaped exception + // here would kill the plugin actor and abort xSTUDIO. A missed snapshot is + // recovered by the next anchor. + try { + + int edited_image_index = 0; + std::vector layout_table; + streaming_grid_layout(annotated_image, edited_image_index, layout_table); + // Keep the last-broadcast cache fresh so snapshots gathered while the + // stream viewport is momentarily unknown reuse this table (see + // streaming_grid_layout) instead of dropping to empty/identity. + if (!layout_table.empty()) + last_streamed_layout_table_ = layout_table; + + // Authoritative frame-scope snapshot: every annotation on the streaming + // viewport's on-screen images. For the annotation just modified we use the + // caller's fresh pointer, not the (possibly stale, queued-write) one held + // by the image's bookmark list. + std::vector annotations; + std::vector image_indices; + bool edited_found = false; + + const media_reader::ImageBufDisplaySetPtr *images = streaming_images(); + if (!images && !anno) { + // Blind gather: we cannot see the streamed frame (e.g. a client joined + // before the stream viewport rendered its first frame - the stream + // starts ON client connect) and there is no fresh annotation to relay. + // Broadcasting an empty snapshot here would wipe the strokes clients + // just received from the sync plugin's cache replay. Stay silent: the + // frame-change anchor fires as soon as the stream viewport renders its + // first paused frame and delivers the real snapshot. + return; + } + if (images) { + const int n = (*images)->num_onscreen_images(); + for (int i = 0; i < n; ++i) { + const auto &im = (*images)->onscreen_image(i); + for (const auto &bookmark : im.bookmarks()) { + if (bookmark->detail_.uuid_ == bookmark_uuid) { + edited_found = true; + if (anno) { + annotations.push_back(anno); + image_indices.push_back(i); + } + } else if ( + dynamic_cast(bookmark->annotation_.get())) { + // Only OUR annotation type: other plugins' annotations + // (e.g. Grading bookmarks) have no strokes to stream and + // an unspecified user_data() contract on the sync side. + annotations.push_back(bookmark->annotation_); + image_indices.push_back(i); + } + } + } + } + // A brand-new bookmark is not attached to the images yet (queued write) - + // its annotation must still be part of the snapshot. + if (!edited_found && anno) { + annotations.push_back(anno); + image_indices.push_back(edited_image_index); + } + + mail( + utility::event_atom_v, + annotation_data_atom_v, + annotations, + image_indices, + layout_table, + frame_changed) + .send(live_edit_event_group_); + + } catch (const std::exception &e) { + spdlog::warn("{} snapshot broadcast failed: {}", __PRETTY_FUNCTION__, e.what()); + } +} + void AnnotationsCore::make_bookmark_for_annotations( const media::AVFrameID &frame_id, const utility::Uuid &bm_id) { @@ -1528,7 +1775,8 @@ void AnnotationsCore::clear_annotation(LiveEditData &user_edit_data) { bookmark_is_empty); // for Sync plugin, broadcast new state of annotation after clear - mail(utility::event_atom_v, annotation_data_atom_v, anno_ptr).send(live_edit_event_group_); + broadcast_committed_annotation( + anno_ptr, user_edit_data->edited_bookmark_id, user_edit_data->annotated_image); if (!bookmark_is_empty) { update_bookmark_annotation(user_edit_data->edited_bookmark_id, anno_ptr, false); @@ -1590,6 +1838,13 @@ void AnnotationsCore::push_live_edit_to_bookmark(LiveEditData &user_edit_data) { AnnotationBasePtr anno_ptr(mod_annotation); update_bookmark_annotation(user_edit_data->edited_bookmark_id, anno_ptr, false); + // Post-mutation snapshot for the sync/web clients: anno_ptr holds the + // annotation WITH the just-committed stroke/caption, regardless of the + // queued bookmark write above. This is the message that lets a web client + // retire its local prediction copy of the stroke (read-your-writes). + broadcast_committed_annotation( + anno_ptr, user_edit_data->edited_bookmark_id, user_edit_data->annotated_image); + // user_edit_data->live_canvas->full_clear(); if (concat) { // really awkward! We've just made a new bookmark. This will be 'broadcast' @@ -1618,20 +1873,29 @@ void AnnotationsCore::start_cursor_blink() { } } +void AnnotationsCore::commit_laser_stroke(LiveEditData &user_edit_data) { + // move the in-progress stroke into the fading set. Until now it was held + // in live_laser_stroke at full opacity; once in laser_strokes it starts + // fading on the next animation tick. + if (user_edit_data->live_laser_stroke) { + user_edit_data->laser_strokes.push_back( + std::move(user_edit_data->live_laser_stroke)); + } +} + void AnnotationsCore::fade_all_laser_strokes() { int n = 0; for (auto &p : live_edit_data_) { - bool drawing = p.second->item_type == Canvas::ItemType::Laser; + // An in-progress stroke (mouse still held) is kept in live_laser_stroke + // and must not fade. We still count it so the animation loop (and its + // per-tick redraw) stays alive while the user is drawing. + if (p.second->live_laser_stroke) + n++; auto &strokes = p.second->laser_strokes; auto q = strokes.begin(); while (q != strokes.end()) { - bool fully_faded = (*q)->fade(0.01f); - // Only the back element while drawing must be preserved — it's - // the active stroke and erasing it would invalidate the back() - // reference used by modify_stroke_or_shape. - bool is_active = drawing && (std::next(q) == strokes.end()); - if (fully_faded && !is_active) { + if ((*q)->fade(0.01f)) { q = strokes.erase(q); } else { n++; @@ -1639,25 +1903,22 @@ void AnnotationsCore::fade_all_laser_strokes() { } } } - // laser strokes have all faded to nothing. + // no in-progress or fading laser strokes remain. if (!n) laser_stroke_animation_ = false; } void AnnotationsCore::annotation_about_to_be_edited( - const AnnotationBasePtr &anno, const utility::Uuid &anno_uuid) { - - if (anno && anno_uuid != current_edited_annotation_uuid_) { - current_edited_annotation_uuid_ = anno_uuid; - mail(utility::event_atom_v, annotation_data_atom_v, anno).send(live_edit_event_group_); - } else if (anno_uuid != current_edited_annotation_uuid_) { - current_edited_annotation_uuid_ = anno_uuid; - mail( - utility::event_atom_v, - annotation_data_atom_v, - anno_uuid.is_null() ? AnnotationBasePtr() : AnnotationBasePtr(new Annotation)) - .send(live_edit_event_group_); - } + const AnnotationBasePtr &anno, + const utility::Uuid &anno_uuid, + const media_reader::ImageBufPtr &annotated_image) { + + // Tracking only. Snapshots for the sync/web clients are broadcast by the + // authoritative anchors instead: frame change (images_going_on_screen), + // stroke commit (push_live_edit_to_bookmark), undo/redo/clear and client + // join - an edit-start broadcast is redundant with those and just adds + // message churn while drawing. + current_edited_annotation_uuid_ = anno_uuid; } diff --git a/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.hpp b/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.hpp index d65e7eb16..0b2e25b2f 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.hpp +++ b/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.hpp @@ -30,6 +30,9 @@ class AnnotationsCore : public plugin::StandardPlugin { const std::string viewport_name, const bool playhead_playing) override; + void maybe_broadcast_streamed_frame( + const std::string &viewport_name, const bool playhead_playing); + utility::BlindDataObjectPtr onscreen_render_data( const media_reader::ImageBufPtr &, const std::string & /*viewport_name*/, @@ -56,7 +59,11 @@ class AnnotationsCore : public plugin::StandardPlugin { // some overlay graphics needed. struct LiveEdit { media_reader::ImageBufPtr annotated_image; + // committed laser strokes that are fading out (mouse already released) std::vector> laser_strokes; + // the in-progress laser stroke (mouse still held); never fades and is + // moved into laser_strokes on completion. Mirrors live_stroke. + std::shared_ptr live_laser_stroke; std::shared_ptr live_stroke; std::shared_ptr live_caption; ui::canvas::Canvas::ItemType item_type = {ui::canvas::Canvas::ItemType::None}; @@ -147,12 +154,53 @@ class AnnotationsCore : public plugin::StandardPlugin { void broadcast_live_laser_stroke( const utility::Uuid &user_id, const bool stroke_completed = false); + // The on-screen image set of the streaming (offscreen) viewport - the one + // the video encoder flagged for stroke suppression - or, failing that, any + // viewport with images (the cell layout is view-independent). Null when no + // viewport is known. + const media_reader::ImageBufDisplaySetPtr *streaming_images() const; + + // For the streaming (offscreen) viewport, build the per-cell layout table + // (each on-screen image's layout_transform, indexed by cell) and set + // `image_index` to the cell whose frame matches `annotated_image`. Lets the + // web client position streamed strokes per grid cell without a per-stroke + // matrix. In single-image mode the table is one identity entry and + // image_index is 0. Empty table when no streaming viewport / on-screen + // images are known. + void streaming_grid_layout( + const media_reader::ImageBufPtr &annotated_image, + int &image_index, + std::vector &layout_table) const; + + // Broadcast an authoritative post-mutation snapshot of EVERY annotation on + // the streaming viewport's on-screen images (frame scope), substituting + // `anno` for `bookmark_uuid` - the images can hold a stale pointer for the + // annotation that was just modified, because bookmark writes are queued. + // The web client replaces its committed stroke set wholesale with this + // snapshot; its own in-flight strokes ride a separate prediction overlay + // until their id appears in one of these snapshots. + // `frame_changed` marks a snapshot triggered by the streamed FRAME + // changing (not an edit): clients apply it in sync with the delayed video + // (holding the previous frame's strokes over the playout window) instead + // of immediately. + void broadcast_committed_annotation( + const bookmark::AnnotationBasePtr &anno, + const utility::Uuid &bookmark_uuid, + const media_reader::ImageBufPtr &annotated_image, + const bool frame_changed = false); + + // move the in-progress laser stroke into the fading set; this is what + // starts the fade (called when the stroke is complete / mouse released) + void commit_laser_stroke(LiveEditData &user_edit_data); + void start_cursor_blink(); void fade_all_laser_strokes(); void annotation_about_to_be_edited( - const bookmark::AnnotationBasePtr &anno, const utility::Uuid &anno_uuid); + const bookmark::AnnotationBasePtr &anno, + const utility::Uuid &anno_uuid, + const media_reader::ImageBufPtr &annotated_image = media_reader::ImageBufPtr()); void remove_bookmark(const utility::Uuid &bookmark_id) { plugin::StandardPlugin::remove_bookmark(bookmark_id); @@ -195,6 +243,19 @@ class AnnotationsCore : public plugin::StandardPlugin { std::map viewport_transforms_; std::map viewport_current_images_; + // Last per-cell layout table broadcast for the streaming viewport - used to + // broadcast the table only when the layout actually changes (compare-mode / + // grid switch, or first frame on screen), so the web client knows the cell + // layout before any annotation event happens. + std::vector last_streamed_layout_table_; + + // Frame keys of the streaming viewport's on-screen images when we last + // broadcast a frame-change snapshot. The stream never carries strokes, so + // clients depend on a snapshot arriving when the streamed frame changes - + // this dedupes that anchor. Cleared during playback so the first paused + // frame always broadcasts. + std::vector last_streamed_frame_keys_; + module::StringAttribute *note_category_{nullptr}; module::StringAttribute *note_colour_{nullptr}; @@ -205,6 +266,15 @@ class AnnotationsCore : public plugin::StandardPlugin { std::atomic_bool cursor_blink_; std::map hide_strokes_per_viewport_; std::map hide_all_per_viewport_; + std::map visibility_override_per_viewport_; + + // current AnnotationsVisibilityOverride for the given viewport + // (VO_DEFAULT if none has been set) + int visibility_override(const std::string &viewport_name) const { + auto p = visibility_override_per_viewport_.find(viewport_name); + return p != visibility_override_per_viewport_.end() ? p->second->load() + : (int)VO_DEFAULT; + } caf::actor live_edit_event_group_; caf::actor draw_events_event_group_; utility::Uuid current_edited_annotation_uuid_; diff --git a/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.cpp b/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.cpp index 9a93d0866..4c9df2346 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.cpp +++ b/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.cpp @@ -252,6 +252,12 @@ void AnnotationsUI::attribute_changed(const utility::Uuid &attribute_uuid, const if (current_tool() != Dropper) { + // picking a drawing tool re-enables annotation visibility (no-op + // if already visible) + annotations_visible_->set_value(true); + // the event is sent unconditionally as we may be restoring the + // transient hide from Dropper colour-picking, which does not go + // through the Visibility attribute utility::JsonStore payload; send_event("ShowDrawings", payload); pixel_patch_.hide(); @@ -314,22 +320,23 @@ void AnnotationsUI::attribute_changed(const utility::Uuid &attribute_uuid, const undo(viewport); } else if (action == "Redo") { redo(viewport); - } else if (action == "HideVisibility") { - annotations_visible_->set_value(false); - colour_picker_hide_drawings_->set_value(true, false); - utility::JsonStore payload; - send_event("HideDrawings", payload); - } else if (action == "ShowVisibility") { - annotations_visible_->set_value(true); - colour_picker_hide_drawings_->set_value(false, false); - utility::JsonStore payload; - send_event("ShowDrawings", payload); } action_attribute_->set_role_data( module::Attribute::Value, std::vector(), false); } + } else if (attribute_uuid == annotations_visible_->uuid()) { + + // "Visibility" is the single source of truth for the global show/hide + // of annotations - it can be set from the toolbox button, the hotkey, + // tool selection or externally (e.g. the sync plugin when a review + // client toggles visibility). All side-effects of the toggle happen + // here so every writer behaves identically. + colour_picker_hide_drawings_->set_value(!annotations_visible_->value(), false); + utility::JsonStore payload; + send_event(annotations_visible_->value() ? "ShowDrawings" : "HideDrawings", payload); + } else if (attribute_uuid == display_mode_attribute_->uuid()) { std::string action; @@ -490,10 +497,7 @@ void AnnotationsUI::hotkey_pressed( last_tool_ = current_tool(); active_tool_->set_value(tool_name(Dropper)); } else if (hotkey_uuid == toggle_visibility_hotkey_) { - const std::string visibility_action = - annotations_visible_->value() ? "HideVisibility" : "ShowVisibility"; - action_attribute_->set_role_data( - module::Attribute::Value, std::vector{visibility_action, context}); + annotations_visible_->set_value(!annotations_visible_->value()); } } diff --git a/src/plugin/viewport_overlay/annotations/src/qml/AnnotationsUI.2/XsDrawingTools.qml b/src/plugin/viewport_overlay/annotations/src/qml/AnnotationsUI.2/XsDrawingTools.qml index 008061eae..aa938e447 100644 --- a/src/plugin/viewport_overlay/annotations/src/qml/AnnotationsUI.2/XsDrawingTools.qml +++ b/src/plugin/viewport_overlay/annotations/src/qml/AnnotationsUI.2/XsDrawingTools.qml @@ -63,6 +63,11 @@ Item { attributeTitle: "action_attribute" model: annotations_model_data } + XsAttributeValue { + id: annotations_visible_attr + attributeTitle: "Visibility" + model: annotations_model_data + } property alias currentTool: active_tool.value property alias toolChoices: tool_types_choices.value @@ -71,7 +76,8 @@ Item { property var current_tool_properties onCurrentToolChanged: { - action_attr.value = ["ShowVisibility", view.name] + // picking a tool re-enables annotation visibility + annotations_visible_attr.value = true if(currentTool === "Draw") { diff --git a/src/plugin/viewport_overlay/annotations/src/qml/AnnotationsUI.2/XsToolActions.qml b/src/plugin/viewport_overlay/annotations/src/qml/AnnotationsUI.2/XsToolActions.qml index a302e9533..32a2cb6af 100644 --- a/src/plugin/viewport_overlay/annotations/src/qml/AnnotationsUI.2/XsToolActions.qml +++ b/src/plugin/viewport_overlay/annotations/src/qml/AnnotationsUI.2/XsToolActions.qml @@ -118,9 +118,7 @@ GridLayout { Layout.preferredWidth: horizontal ? 70 : -1 onClicked: { - action_attribute = annotations_visible - ? ["HideVisibility", view.name] - : ["ShowVisibility", view.name] + annotations_visible = !annotations_visible } } diff --git a/src/plugin_manager/src/plugin_manager_actor.cpp b/src/plugin_manager/src/plugin_manager_actor.cpp index 1fbe4f72b..35b4d141c 100644 --- a/src/plugin_manager/src/plugin_manager_actor.cpp +++ b/src/plugin_manager/src/plugin_manager_actor.cpp @@ -393,6 +393,7 @@ PluginManagerActor::PluginManagerActor(caf::actor_config &cfg) : caf::event_base const std::string name, const utility::JsonStore &json, const std::string class_name) -> result { + /*if (base_plugins_.find(name) == base_plugins_.end()) { base_plugins_[name] = spawn(name, json); link_to(base_plugins_[name]); diff --git a/src/python_module/src/py_atoms.cpp b/src/python_module/src/py_atoms.cpp index 601dcd617..796c3b06f 100644 --- a/src/python_module/src/py_atoms.cpp +++ b/src/python_module/src/py_atoms.cpp @@ -88,6 +88,7 @@ void py_config::add_atoms() { ADD_ATOM(xstudio::timeline, trimmed_range_atom); ADD_ATOM(xstudio::timeline, item_selection_atom); ADD_ATOM(xstudio::timeline, item_type_atom); + ADD_ATOM(xstudio::timeline, audio_mode_atom); ADD_ATOM(xstudio::thumbnail, cache_path_atom); ADD_ATOM(xstudio::thumbnail, cache_stats_atom); @@ -179,6 +180,7 @@ void py_config::add_atoms() { ADD_ATOM(xstudio::colour_pipeline, colour_pipeline_atom); ADD_ATOM(xstudio::colour_pipeline, get_colour_pipe_data_atom); ADD_ATOM(xstudio::colour_pipeline, get_colour_pipe_params_atom); + ADD_ATOM(xstudio::colour_pipeline, get_colourspace_info_atom); ADD_ATOM(xstudio::module, add_attribute_atom); ADD_ATOM(xstudio::module, attribute_role_data_atom); diff --git a/src/python_module/src/py_context.cpp b/src/python_module/src/py_context.cpp index 398a1dc9c..acc53c152 100644 --- a/src/python_module/src/py_context.cpp +++ b/src/python_module/src/py_context.cpp @@ -13,6 +13,25 @@ namespace caf::python { +class ExitMonitor: public caf::event_based_actor { + public: + ExitMonitor(caf::actor_config &cfg, py_context *context) + : caf::event_based_actor(cfg), context_(context) { + + behavior_.assign( + [=](node_down_msg &_msg) { + context_->xstudio_down(); + }); + } + + ~ExitMonitor() override = default; + + caf::behavior behavior_; + py_context *context_; + + caf::behavior make_behavior() override { return behavior_; } +}; + /* This actor receives event messages from actors (xstudio components) that Python plugins want to get messages from. It then sends the message back to the py_context object which has registered the python callback functions @@ -135,16 +154,19 @@ py_context::py_context(int argc, char **argv) py_context::~py_context() { // shutdown system + if (exit_monitor_) { + self_->send_exit(exit_monitor_, caf::exit_reason::user_shutdown); + } disconnect(); } -std::optional py_context::py_build_message(const py::args &xs) { +std::optional py_context::py_build_message(const py::args &xs, int skip_args) { if (xs.size() < 2) { set_py_exception("Too few arguments to call build_message"); return {}; } auto i = xs.begin(); - ++i; + while (skip_args--) ++i; message_builder mb; for (; i != xs.end(); ++i) { std::string type_name = PyEval_GetFuncName((*i).ptr()); @@ -323,20 +345,95 @@ void py_context::execute_event_callback( #pragma GCC diagnostic ignored "-Wdeprecated-declarations" #endif -uint64_t py_context::py_request(const py::args &xs) { +static std::mutex mmm; + +py::tuple py_context::py_request(const py::args &xs) { + + // this is called from Connection Python class + + py::tuple result; if (xs.size() < 2) { set_py_exception("Too few arguments to call CAF.request"); - return 0; + return result; } auto i = xs.begin(); + auto timeout_milliseconds = (*i).cast(); + ++i; auto dest = (*i).cast(); - auto msg = py_build_message(xs); + auto msg = py_build_message(xs, 2); if (msg) { + + // send the request - this gives us a message id which we use to + // retrieve the result from the mailbox. + + // Note: the GIL is held here, so as far as I know it's impossible for + // another request to be made before we get the result that we are + // looking for (in other words messages coming in out-of-order or + // other async issues should not trouble us) auto reqhan = self_->request(dest, caf::infinite, *msg); - return reqhan.id().request_id().integer_value(); + + if (self_->has_next_message()) { + // Not sure if we'll ever get the message back here but just in case + mailbox_element_ptr ptr = self_->next_message(); + if (ptr) { + if (ptr->mid.request_id().integer_value() == reqhan.id().request_id().integer_value()) { + if (ptr->content().match_element(0)) { + set_py_exception(to_string(ptr->content().get_as(0))); + return py::tuple{}; + } + return tuple_from_message(ptr->mid, ptr->sender, std::move(ptr->content())); + } + } + } + + // loop here until we time-out or we get the result we're looking for. + while (1) { + + auto t0 = std::chrono::high_resolution_clock::now(); + auto to = t0 + std::chrono::milliseconds(timeout_milliseconds); + { + // Note: I have tried releasing the GIL here, so that other + // python threads can proceed. However, sometimes we get a + // lockup when the scoped release goes of our scope and it + // tries to re-acquire the GIL - not been able to resolve this + // so far. + // py::gil_scoped_release release; + + // blocking wait with timeout + if (!self_->await_data(to)) { + set_py_exception(R"(Dequeue timeout)"); + return py::tuple{}; + } + + } + mailbox_element_ptr ptr = self_->next_message(); + if (ptr) { + if (ptr->mid.request_id().integer_value() == reqhan.id().request_id().integer_value()) { + if (ptr->content().match_element(0)) { + set_py_exception(to_string(ptr->content().get_as(0))); + return py::tuple{}; + } + return tuple_from_message(ptr->mid, ptr->sender, std::move(ptr->content())); + } else { + //std::cerr << "Missed message " << to_string(ptr->content()) << "\n"; + } + } + + timeout_milliseconds -= std::chrono::duration_cast(std::chrono::high_resolution_clock::now()-t0).count(); + if (timeout_milliseconds < 0) { + set_py_exception(R"(Dequeue timeout)"); + return py::tuple{}; + } + } } - return 0; + return py::tuple{}; +} + +void py_context::py_wait_for_xstudio_exit() { + py::gil_scoped_release release; + std::unique_lock lk(mutex_); + cv_.wait(lk, [this]{ return remote_has_exited_; }); } #ifdef __GNUC__ // Check if GCC compiler is being used @@ -356,6 +453,7 @@ void py_context::py_send_exit(const py::args &xs) { py::tuple py_context::tuple_from_message( const message_id mid, const strong_actor_ptr sender, const message &msg) { + py::tuple result(msg.size() + 2); // spdlog::warn("is_async {}, is_request {}, is_response {} msg size {}", // mid.is_async(), mid.is_request(), mid.is_response(), msg.size()); @@ -420,18 +518,22 @@ py::tuple py_context::py_dequeue_with_timeout(xstudio::utility::absolute_receive_timeout timeout) { mailbox_element_ptr ptr = nullptr; - if (self_->has_next_message()) { - // std::cout << "has message already" << std::endl; - ptr = self_->next_message(); - } + { + //py::gil_scoped_release release; - while (!ptr) { - if (!self_->await_data(timeout.value())) { - set_py_exception(R"(Dequeue timeout)"); - return py::tuple{}; + if (self_->has_next_message()) { + // std::cout << "has message already" << std::endl; + ptr = self_->next_message(); + } + + while (!ptr) { + if (!self_->await_data(timeout.value())) { + set_py_exception(R"(Dequeue timeout)"); + return py::tuple{}; + } + // std::cout << "got message" << std::endl; + ptr = self_->next_message(); } - // std::cout << "got message" << std::endl; - ptr = self_->next_message(); } return tuple_from_message(ptr->mid, ptr->sender, std::move(ptr->content())); } @@ -509,11 +611,15 @@ bool py_context::connect_remote(std::string host, uint16_t port) { embedded_python_actor_ = system_.registry().template get(xstudio::embedded_python_registry); + exit_monitor_ = system_.spawn(this); + auto actor = system_.middleman().remote_actor(host, port); if (actor) { remote_ = *actor; host_ = host; port_ = port; + system_.monitor(remote_.node(), caf::actor_cast(exit_monitor_)); + remote_has_exited_ = false; } return static_cast(actor); } diff --git a/src/python_module/src/py_context.hpp b/src/python_module/src/py_context.hpp index 4d1332ba0..b3d7ab568 100644 --- a/src/python_module/src/py_context.hpp +++ b/src/python_module/src/py_context.hpp @@ -32,11 +32,11 @@ class py_context : public py_config { virtual ~py_context(); - std::optional py_build_message(const py::args &xs); + std::optional py_build_message(const py::args &xs, int skip_args=1); void py_send(const py::args &xs); void py_join(const py::args &xs); void py_leave(const py::args &xs); - uint64_t py_request(const py::args &xs); + py::tuple py_request(const py::args &xs); void py_send_exit(const py::args &xs); py::tuple tuple_from_message(const message_id mid, const strong_actor_ptr sender, const message &msg); @@ -47,6 +47,7 @@ class py_context : public py_config { execute_event_callback(const caf::message &msg, const xstudio::utility::Uuid &callback_id); void erase_func(py::function &callback_func); + void py_wait_for_xstudio_exit(); py::tuple py_dequeue(); py::tuple py_dequeue_with_timeout(xstudio::utility::absolute_receive_timeout timeout); xstudio::utility::Uuid py_add_message_callback(const py::args &xs); @@ -67,6 +68,13 @@ class py_context : public py_config { bool connect_local(caf::actor actor); std::string host() { return host_; } uint16_t port() { return port_; } + void xstudio_down() { + { + std::lock_guard l(mutex_); + remote_has_exited_ = true; + } + cv_.notify_one(); + } public: @@ -93,8 +101,12 @@ class py_context : public py_config { scoped_actor self_; actor remote_; actor embedded_python_actor_; + actor exit_monitor_; py::function my_func; - std::thread my_thread; + + bool remote_has_exited_; + std::mutex mutex_; + std::condition_variable cv_; caf::actor message_callback_handler_actor_; std::map message_callback_funcs_; diff --git a/src/python_module/src/py_link.cpp b/src/python_module/src/py_link.cpp index d14c9ba81..5f9f34f35 100644 --- a/src/python_module/src/py_link.cpp +++ b/src/python_module/src/py_link.cpp @@ -28,7 +28,11 @@ void py_link(py::module_ &m) { .def("connect_remote", &caf::python::py_context::connect_remote) .def("connect_local", &caf::python::py_context::connect_local) .def("disconnect", &caf::python::py_context::disconnect) - .def("send", &caf::python::py_context::py_send, "Sends a message to an actor") + .def("send", &caf::python::py_context::py_send, "Sends a message to an actor") + .def( + "wait_for_xstudio_exit", + &caf::python::py_context::py_wait_for_xstudio_exit, + "Blocks the calling thread until xSTUDIO exits.") .def( "request", &caf::python::py_context::py_request, diff --git a/src/python_module/src/py_messages.cpp b/src/python_module/src/py_messages.cpp index 165ab56f2..0168c2a82 100644 --- a/src/python_module/src/py_messages.cpp +++ b/src/python_module/src/py_messages.cpp @@ -189,6 +189,9 @@ void py_config::add_messages() { add_message_type( "ItemType", "xstudio::timeline::ItemType", nullptr); + add_message_type( + "AudioMode", "xstudio::timeline::AudioMode", nullptr); + add_message_type( "FrameRange", "xstudio::utility::FrameRange", ®ister_frame_range_class); diff --git a/src/python_module/src/py_register.cpp b/src/python_module/src/py_register.cpp index f04f0da90..0a8179ccb 100644 --- a/src/python_module/src/py_register.cpp +++ b/src/python_module/src/py_register.cpp @@ -279,6 +279,7 @@ void register_mediareference_class(py::module &m, const std::string &name) { .def("__str__", str_impl) .def("container", &utility::MediaReference::container) .def("frame_count", &utility::MediaReference::frame_count) + .def("frame_list", &utility::MediaReference::frame_list) .def("fill_partial_sequences", &utility::MediaReference::fill_partial_sequences) .def( "seconds", @@ -658,6 +659,7 @@ void register_frame_list_class(py::module &m, const std::string &name) { .def(py::init<>()) .def(py::init()) .def(py::init()) + .def("frames", &utility::FrameList::frames) .def("__str__", str_impl); } diff --git a/src/python_module/src/py_xstudio.cpp b/src/python_module/src/py_xstudio.cpp index e025827b6..4fc5e1e47 100644 --- a/src/python_module/src/py_xstudio.cpp +++ b/src/python_module/src/py_xstudio.cpp @@ -125,6 +125,11 @@ PYBIND11_MODULE(__pybind_xstudio, m) { .value("IT_TIMELINE", timeline::ItemType::IT_TIMELINE) .export_values(); + py::enum_(m, "AudioMode") + .value("AM_USE_AUDIO_STACK", timeline::AudioMode::AM_USE_AUDIO_STACK) + .value("AM_USE_VIDEO_STACK", timeline::AudioMode::AM_USE_VIDEO_STACK) + .export_values(); + py::enum_(m, "StatusType") .value("ST_NONE", global::StatusType::ST_NONE) .value("ST_BUSY", global::StatusType::ST_BUSY) diff --git a/src/session/src/CMakeLists.txt b/src/session/src/CMakeLists.txt index 4bacb99d1..cf96f9758 100644 --- a/src/session/src/CMakeLists.txt +++ b/src/session/src/CMakeLists.txt @@ -1,3 +1,5 @@ +find_package(ZLIB REQUIRED) + SET(LINK_DEPS xstudio::playlist xstudio::utility @@ -6,3 +8,7 @@ SET(LINK_DEPS ) create_component(session ${XSTUDIO_GLOBAL_VERSION} "${LINK_DEPS}") + +# session_actor.cpp uses zstr (zlib) directly. utility no longer exports +# ZLIB::ZLIB publicly, so link it here as a private implementation detail. +target_link_libraries(session PRIVATE ZLIB::ZLIB) diff --git a/src/session/src/session_actor.cpp b/src/session/src/session_actor.cpp index 79c687c37..647907358 100644 --- a/src/session/src/session_actor.cpp +++ b/src/session/src/session_actor.cpp @@ -17,6 +17,7 @@ #include "xstudio/global_store/global_store.hpp" #include "xstudio/json_store/json_store_actor.hpp" #include "xstudio/bookmark/bookmarks_actor.hpp" +#include "xstudio/playhead/playhead_actor.hpp" #include "xstudio/playlist/playlist_actor.hpp" #include "xstudio/session/session_actor.hpp" #include "xstudio/utility/helpers.hpp" @@ -612,7 +613,22 @@ void SessionActor::init() { ioactor_ = spawn(); link_to(ioactor_); - // // monitor serilise targets. + /* Here we create a playhead used exclusively for comparing multiple timelines, + which is a special 'mode' activated when the user selects multiple timelines + in the playlists */ + timeline_compare_playhead_ = UuidActor( + playhead::PlayheadActor::TIMELINE_COMPARE_PLAYHEAD_UUID, + spawn( + std::string("Multi Timeline Compare Playhead"), + playhead::GLOBAL_AUDIO, + caf::actor(), + playhead::PlayheadActor::TIMELINE_COMPARE_PLAYHEAD_UUID)); + + link_to(timeline_compare_playhead_.actor()); + anon_mail(module::change_attribute_request_atom_v, "Compare", int(module::Attribute::Value), utility::JsonStore("Grid")) + .send(timeline_compare_playhead_.actor()); + + // // monitor serilise targets. // set_down_handler([=](down_msg &msg) { // // find in playhead list.. // // if they don't unsubscribe we blow their data..! @@ -739,7 +755,10 @@ caf::message_handler SessionActor::message_handler() { [=](timeline::item_selection_atom) -> UuidActorVector { return selection_; }, [=](timeline::item_selection_atom, const UuidActorVector &selection) { + selection_ = selection; + check_multi_timeline_mode(); + }, [=](get_push_playlist_atom) { @@ -1623,52 +1642,7 @@ caf::message_handler SessionActor::message_handler() { .send(base_.event_group()); }); - // we need to ensure that the Playlist/Subset/Timeline has a playhead and - // that this playhead is broadcast to viewport so they can attach to it. - mail(playlist::create_playhead_atom_v) - .request(viewedContainer_.actor(), infinite) - .then( - [=](utility::UuidActor &playhead) { - // this is a bit nasty - I need to know if this SessionActor - // is the *current* session (it could be a session that's being - // imported) - mail(session::session_atom_v) - .request( - system().registry().template get( - studio_registry), - infinite) - .then( - [=](caf::actor session) { - // If we are not THE active session. Don't try and - // switch - // the global playhead ... - if (caf::actor_cast(this) != session) - return; - - // this actually broadcasts, via the global playhead - // events actor, the new playhead to all viewports - // so they can attach to the playhead - auto playhead_events_actor = - system().registry().template get( - global_playhead_events_actor); - - anon_mail( - ui::viewport::viewport_playhead_atom_v, - playhead.actor()) - .send(playhead_events_actor); - }, - [=](caf::error &err) {}); - - - mail( - utility::event_atom_v, - ui::viewport::viewport_playhead_atom_v, - playhead.uuid()) - .send(base_.event_group()); - }, - [=](caf::error &err) { - - }); + setOnScreenPlayhead(true); } base_.set_viewed_playlist_uuid(viewedContainer_.uuid()); mail( @@ -1751,6 +1725,26 @@ caf::message_handler SessionActor::message_handler() { .send(base_.event_group()); }, + [=](utility::event_atom, timeline::audio_mode_atom, const timeline::AudioMode am, caf::actor timeline, caf::actor playlist) { + // forwarded from timeline via playlist + }, + + [=](utility::event_atom, timeline::audio_mode_atom, const timeline::AudioMode am) { + // from timeline + }, + + [=](xstudio::utility::event_atom atom1, + xstudio::utility::change_atom atom2, + xstudio::timeline::clip_edited_status_atom atom3, + const utility::Uuid &clip_id, + const int status, + const utility::Uuid &track_id, + const utility::Uuid &stack_id, + const utility::Uuid &timeline_id, + const utility::Uuid &playlist_id) { + // coming from timeline (via playlist) to session. ignore. + }, + [=](utility::event_atom, media::media_display_info_atom, const utility::JsonStore &, @@ -2028,9 +2022,9 @@ caf::message_handler SessionActor::message_handler() { [=](utility::event_atom, ui::viewport::viewport_playhead_atom, caf::actor live_playhead) { + // the main, globally active playhead that pushes images to the // viewport (apart from QuickView windows) has changed.... - if (live_playhead) { // we want to get the playlist/subset/timeline that owns this // new playhead @@ -2039,10 +2033,32 @@ caf::message_handler SessionActor::message_handler() { .then( [=](caf::actor_addr playhead_owner) { // the playhead owner is a Playlist, Subset or Timeline - anon_mail( - session::viewport_active_media_container_atom_v, - caf::actor_cast(playhead_owner)) - .send(this); + if (playhead_owner) { + // from this message, we connect to the playlist + // playhead etc. + anon_mail( + session::viewport_active_media_container_atom_v, + caf::actor_cast(playhead_owner)) + .send(this); + } else if (live_playhead) { + // No parent playhead + mail(utility::uuid_atom_v).request(live_playhead, infinite).then( + [=](const utility::Uuid & plyheaid) { + std::cerr << "plyheaid " << to_string(plyheaid) << "\n"; + mail( + utility::event_atom_v, + ui::viewport::viewport_playhead_atom_v, + plyheaid) + .send(base_.event_group()); + }, + [=](caf::error &err) {}); + } else { + mail( + utility::event_atom_v, + ui::viewport::viewport_playhead_atom_v, + utility::Uuid()) + .send(base_.event_group()); + } }, [=](caf::error &err) {}); } else { @@ -2653,4 +2669,184 @@ void SessionActor::gather_media_sources_add_media( spdlog::warn("{} {}", __PRETTY_FUNCTION__, to_string(err)); rp.deliver(err); }); +} + +void SessionActor::check_multi_timeline_mode() { + + /* + + The intention here is that if the user is viewing a timeline and has then + selected one or more other timelines in the session (via Playlists panel + in the UI) then we go into ,multi-timeline compare mode. This is achieved + via a special playhead owned by the SessionActor which we set-up to + compare the timelines. + + Using a map here to preserve the ordering of the selection since the + type_atom request to each of the selected containers will + return a result asynchronously + + */ + auto clear_multi_select_playhead = [=]() mutable { + + anon_mail( + playhead::source_atom_v, + UuidActor(), + utility::UuidActorVector()) + .send(timeline_compare_playhead_.actor()); + + setOnScreenPlayhead(false); + }; + + bool viewed_timeline_is_selected = false; + for (auto & p: selection_) { + if (viewedContainer_ == p) { + viewed_timeline_is_selected = true; + break; + } + } + if (!viewed_timeline_is_selected) { + clear_multi_select_playhead(); + return; + } + + auto ct = std::make_shared(selection_.size()); + auto selected_timelines = std::make_shared>(); + + auto do_check = [=]() mutable { + + auto update_playhead = [=]() mutable { + (*ct)--; + if (!*ct) { + if (selected_timelines->size() > 1) { + + utility::UuidActorVector s; + for (const auto &p: *selected_timelines) { + s.push_back(p.second); + } + + // This message sets up the playhead to play each timeline + // at the same time + anon_mail(playhead::source_atom_v, viewedContainer_, s) + .send(timeline_compare_playhead_.actor()); + + // We now also need to activate our playhead (set it to + // be the viewed playhead) + auto playhead_events_actor = + system().registry().template get(global_playhead_events_actor); + + anon_mail(ui::viewport::viewport_playhead_atom_v, timeline_compare_playhead_.actor()).send(playhead_events_actor); + + + } else { + // Not in multi-compare mode. Clear plahead. + clear_multi_select_playhead(); + } + } + }; + + for (int i = 0; i < int(selection_.size()); ++i) { + auto container_actor = selection_[i]; + if (container_actor == viewedContainer_) { + update_playhead(); + continue; + } + mail(utility::type_atom_v).request(container_actor.actor(), infinite).then( + [=](const std::string &type) mutable { + if (type == "Timeline") { + // selected_timelines respects selection order, except + // for the viewedContainer_ which will always be the + // first item in selected_timelines + (*selected_timelines)[i+1] = container_actor; + } + update_playhead(); + }, + [=](const caf::error &err) mutable { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, to_string(err)); + update_playhead(); + }); + } + + }; + + if (viewedContainer_.actor()) { + mail(utility::type_atom_v).request(viewedContainer_.actor(), infinite) + .then( + [=](const std::string &container_type) mutable { + if (container_type == "Timeline") { + // ensures the currently viewed timeline is the first + // in the list of selected timelines (so it's first + // item in list of sources that playhead sets up for + // comparison) + (*selected_timelines)[0] = viewedContainer_; + do_check(); + } else { + clear_multi_select_playhead(); + } + }, + [=](error &err) { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, to_string(err)); + }); + } + +} + +void SessionActor::setOnScreenPlayhead(const bool check_if_current_session) { + + // we need to ensure that the Playlist/Subset/Timeline has a playhead and + // that this playhead is broadcast to viewport so they can attach to it. + mail(playlist::create_playhead_atom_v) + .request(viewedContainer_.actor(), infinite) + .then( + [=](utility::UuidActor &playhead) { + + if (check_if_current_session) { + // this is a bit nasty - I need to know if this SessionActor + // is the *current* session (it could be a session that's being + // imported) + mail(session::session_atom_v) + .request( + system().registry().template get( + studio_registry), + infinite) + .then( + [=](caf::actor session) { + // If we are not THE active session. Don't try and + // switch + // the global playhead ... + if (caf::actor_cast(this) != session) + return; + + // this actually broadcasts, via the global playhead + // events actor, the new playhead to all viewports + // so they can attach to the playhead + auto playhead_events_actor = + system().registry().template get( + global_playhead_events_actor); + + anon_mail( + ui::viewport::viewport_playhead_atom_v, + playhead.actor()) + .send(playhead_events_actor); + }, + [=](caf::error &err) {}); + } else { + auto playhead_events_actor = + system().registry().template get( + global_playhead_events_actor); + + anon_mail( + ui::viewport::viewport_playhead_atom_v, + playhead.actor()).send(playhead_events_actor); + } + + mail( + utility::event_atom_v, + ui::viewport::viewport_playhead_atom_v, + playhead.uuid()) + .send(base_.event_group()); + }, + [=](caf::error &err) { + + }); + } \ No newline at end of file diff --git a/src/shotgun_client/src/shotgun_client_actor.cpp b/src/shotgun_client/src/shotgun_client_actor.cpp index a84abf754..3e0519dbb 100644 --- a/src/shotgun_client/src/shotgun_client_actor.cpp +++ b/src/shotgun_client/src/shotgun_client_actor.cpp @@ -20,15 +20,16 @@ using namespace caf; using sce = shotgun_client_error; -ShotgunClientActor::ShotgunClientActor(caf::actor_config &cfg) : caf::event_based_actor(cfg) { - init(); -} +ShotgunClientActor::ShotgunClientActor(caf::actor_config &cfg, + const time_t &connection_timeout, + const time_t &read_timeout, + const time_t &write_timeout +) : caf::event_based_actor(cfg) { -void ShotgunClientActor::init() { spdlog::debug("Created ShotgunClientActor"); print_on_exit(this, "ShotgunClientActor"); - http_ = spawn(CPPHTTPLIB_CONNECTION_TIMEOUT_SECOND, 20, 20); + http_ = spawn(connection_timeout, read_timeout, write_timeout); link_to(http_); diff --git a/src/timeline/src/clip.cpp b/src/timeline/src/clip.cpp index 83ac41808..fb6099437 100644 --- a/src/timeline/src/clip.cpp +++ b/src/timeline/src/clip.cpp @@ -72,3 +72,16 @@ utility::JsonStore Clip::serialise() const { return jsn; } + +int Clip::clip_has_been_edited() const { + + int rt = 0; + static const auto original_start_ptr = nlohmann::json::json_pointer("/original_cut_range/start"); + static const auto original_duration_ptr = nlohmann::json::json_pointer("/original_cut_range/duration"); + auto orig_start = item_.prop().value(original_start_ptr, -INT_MAX); + auto orig_duration = item_.prop().value(original_duration_ptr, -INT_MAX); + if (orig_start != -INT_MAX and orig_duration != -INT_MAX) { + rt = int((item_.trimmed_range().frame_duration().frames()) != orig_duration or item_.trimmed_range().frame_start().frames() != orig_start); + } + return rt; +} \ No newline at end of file diff --git a/src/timeline/src/clip_actor.cpp b/src/timeline/src/clip_actor.cpp index 225f40fe0..8e497f07f 100644 --- a/src/timeline/src/clip_actor.cpp +++ b/src/timeline/src/clip_actor.cpp @@ -303,6 +303,20 @@ caf::message_handler ClipActor::message_handler() { return jsn; }, + [=](item_prop_atom, const JsonStore &value, const std::string &key, bool) -> JsonStore { + auto prop = base_.item().prop(); + try { + prop[key] = value; + } catch (const std::exception &err) { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); + } + auto jsn = base_.item().set_prop(prop); + if (not jsn.is_null()) + mail(event_atom_v, item_atom_v, jsn, false).send(base_.event_group()); + + return jsn; + }, + [=](item_prop_atom, const JsonStore &value, const std::string &path) -> JsonStore { auto prop = base_.item().prop(); try { @@ -314,16 +328,54 @@ caf::message_handler ClipActor::message_handler() { auto jsn = base_.item().set_prop(prop); if (not jsn.is_null()) mail(event_atom_v, item_atom_v, jsn, false).send(base_.event_group()); + return jsn; }, [=](item_prop_atom) -> JsonStore { return base_.item().prop(); }, - [=](active_range_atom, const FrameRange &fr) -> JsonStore { + // ignore from media actor + [=](utility::event_atom, notification_atom, const JsonStore &) {}, + + [=](active_range_atom, const FrameRange &fr) { + + return mail(active_range_atom_v, fr, true).delegate(actor_cast(this)); + + }, + + [=](clip_edited_status_atom) -> int { + return base_.clip_has_been_edited(); + }, + + [=](active_range_atom, const FrameRange &fr, const bool user_edit) -> JsonStore { + + const bool was_edited = base_.clip_has_been_edited(); + + if (user_edit && !base_.item().prop().contains("original_cut_range") && base_.item().trimmed_range().frame_duration().frames()) { + // on first user edit, store the trimmed range as 'original_cut_range' so we can + // restore the cut to it's original state if we ever need to + JsonStore store_range; + store_range["start"] = base_.item().trimmed_range().frame_start().frames(); + store_range["duration"] = base_.item().trimmed_range().frame_duration().frames(); + auto prop = base_.item().prop(); + prop["original_cut_range"] = store_range; + auto jsn = base_.item().set_prop(prop); + if (not jsn.is_null()) + mail(event_atom_v, item_atom_v, jsn, false).send(base_.event_group()); + } + auto jsn = base_.item().set_active_range(fr); - if (not jsn.is_null()) + if (not jsn.is_null()) { mail(event_atom_v, item_atom_v, jsn, false).send(base_.event_group()); + } + + if (was_edited != base_.clip_has_been_edited()) { + // send a message to the session model to update the clip edited status role + mail(event_atom_v, change_atom_v, clip_edited_status_atom_v, base_.item().uuid(), base_.clip_has_been_edited()).send(base_.event_group()); + } + return jsn; + }, [=](available_range_atom, const FrameRange &fr) -> JsonStore { @@ -333,7 +385,7 @@ caf::message_handler ClipActor::message_handler() { return jsn; }, - [=](active_range_atom) -> std::optional { + [=](active_range_atom) { return base_.item().active_range(); }, diff --git a/src/timeline/src/item.cpp b/src/timeline/src/item.cpp index ab73b4030..a35f6624e 100644 --- a/src/timeline/src/item.cpp +++ b/src/timeline/src/item.cpp @@ -405,7 +405,8 @@ void Item::resolve_and_request_clip_frames( const utility::FrameRate timeline_frame_rate, const media::MediaType mt, const UuidSet &focus, - bool only_if_focussed) const { + bool only_if_focussed, + const AudioMode audio_mode) const { // This function recurses down through the tree of Timeline Item objects. @@ -449,19 +450,23 @@ void Item::resolve_and_request_clip_frames( timeline_frame_rate, mt, focus, - only_if_focussed); + only_if_focussed, + audio_mode); } break; case IT_STACK: { - const auto track_type = + + // Note: audio_mode can force us to request audio from video tracks. + const auto desired_track_type = + (mt == media::MediaType::MT_AUDIO && audio_mode == AM_USE_VIDEO_STACK) ? IT_VIDEO_TRACK : mt == media::MediaType::MT_IMAGE ? IT_VIDEO_TRACK : IT_AUDIO_TRACK; for (const auto &it : *this) { - // Skip tracks whose media type doesn't match requested media type - if (it.transparent() or it.item_type() != track_type) + if (it.transparent() or it.item_type() != desired_track_type) continue; + it.resolve_and_request_clip_frames( timeline_start, helper, @@ -470,7 +475,8 @@ void Item::resolve_and_request_clip_frames( timeline_frame_rate, mt, focus, - only_if_focussed); + only_if_focussed, + audio_mode); } } break; @@ -489,7 +495,8 @@ void Item::resolve_and_request_clip_frames( timeline_frame_rate, mt, focus, - only_if_focussed); + only_if_focussed, + audio_mode); ts += it.trimmed_duration(); } } break; @@ -1798,6 +1805,7 @@ caf::typed_response_promise Item::get_all_frame_IDs( const media::MediaType media_type, const TimeSourceMode tsm, const FrameRate & /*override_rate*/, + const timeline::AudioMode audio_mode, const UuidSet &focus_list) { // This crucial function bakes a timeline into a 'FrameTimeMap' which is @@ -1859,7 +1867,8 @@ caf::typed_response_promise Item::get_all_frame_IDs( rate(), media_type, focus_list, - true // only print frames into map for focussed items + true, // only print frames into map for focussed items + audio_mode ); } @@ -1871,7 +1880,8 @@ caf::typed_response_promise Item::get_all_frame_IDs( rate(), media_type, focus_list, - false // print frames into map whether focussed or not + false, // print frames into map whether focussed or not + audio_mode ); return rp; diff --git a/src/timeline/src/stack_actor.cpp b/src/timeline/src/stack_actor.cpp index d2475800a..0aadbf8d8 100644 --- a/src/timeline/src/stack_actor.cpp +++ b/src/timeline/src/stack_actor.cpp @@ -604,6 +604,51 @@ caf::message_handler StackActor::message_handler() { return rp; }, + [=](duplicate_atom, const utility::UuidVector &selection_for_duplication) -> result { + + // called from TimelineActor ... we duplicate any tracks in 'selection_for_duplication' + // or any tracks with clips in 'selection_for_duplication' (but replacing clips that are + // not in selection_for_duplication with Gaps). + auto rp = make_response_promise(); + + bool in_selection = false; + for (const auto &i : selection_for_duplication) { + auto item = find_item(base_.item().children(), i); + if (item) { + in_selection = true; + } + } + + if (not in_selection) { + rp.deliver(UuidActor()); + } else { + + if (actors_.empty()) { + rp.deliver(UuidActorVector()); + } else { + // duplicate all children and relink against items. + scoped_actor sys{system()}; + UuidActorVector r; + + for (const auto &i : base_.children()) { + auto ua = + request_receive( + *sys, + actors_[i.uuid()], + duplicate_atom_v, + selection_for_duplication + ); + if (ua) { + r.push_back(ua); + } + } + rp.deliver(r); + } + } + return rp; + + }, + [=](duplicate_atom) -> result { auto rp = make_response_promise(); JsonStore jsn; @@ -636,6 +681,13 @@ caf::message_handler StackActor::message_handler() { mail(event_atom_v, change_atom_v).send(base_.event_group()); }, + [=](event_atom, change_atom, clip_edited_status_atom, const utility::Uuid clip_id, const int edited_status, const utility::Uuid track_id) { + + // event coming from a clip via track. Pass up to timeline + mail(event_atom_v, change_atom_v, clip_edited_status_atom_v, clip_id, edited_status, track_id, base_.item().uuid()).send(base_.event_group()); + + }, + [=](serialise_atom) -> result { if (not actors_.empty()) { auto rp = make_response_promise(); @@ -726,6 +778,7 @@ void StackActor::insert_items( vector_to_caf_actor_vector(uav), infinite, item_atom_v) .then( [=](std::vector items) mutable { + // items are valid for insertion ? for (const auto &i : items) { if (not base_.item().valid_child(i)) diff --git a/src/timeline/src/timeline.cpp b/src/timeline/src/timeline.cpp index 0c5e5b4d5..25eaf06e8 100644 --- a/src/timeline/src/timeline.cpp +++ b/src/timeline/src/timeline.cpp @@ -21,7 +21,8 @@ Timeline::Timeline( Timeline::Timeline(const JsonStore &jsn) : Container(static_cast(jsn.at("container"))), item_(static_cast(jsn.at("item"))), - media_list_(static_cast(jsn.at("media"))) {} + media_list_(static_cast(jsn.at("media"))), + audio_mode_(static_cast(jsn.value("audio_mode", 0))) {} JsonStore Timeline::serialise() const { JsonStore jsn; @@ -29,6 +30,7 @@ JsonStore Timeline::serialise() const { jsn["container"] = Container::serialise(); jsn["item"] = item_.serialise(1); jsn["media"] = media_list_.serialise(); + jsn["audio_mode"] = audio_mode(); return jsn; } @@ -43,6 +45,7 @@ Timeline Timeline::duplicate() const { jsn["container"] = dup_container.serialise(); jsn["item"] = dup_item.serialise(1); jsn["media"] = media_list_.serialise(); + jsn["audio_mode"] = audio_mode(); return {jsn}; } \ No newline at end of file diff --git a/src/timeline/src/timeline_actor.cpp b/src/timeline/src/timeline_actor.cpp index 933e5079f..9c9cbc69b 100644 --- a/src/timeline/src/timeline_actor.cpp +++ b/src/timeline/src/timeline_actor.cpp @@ -425,7 +425,17 @@ void process_item( anon_mail(item_prop_atom_v, metadata, "").send(actor); - auto source_range = ii->source_range(); + // [PTSUP-338477] Ted - I'm commenting out the lines below where the + // active range is set for a track from the OTIO source range. + // + // The reason is that the xstudio timeline doesn't support tracks + // where the active_range != available_range ... we don't have a + // way to trim tracks in this way. If the active range is set for + // a track, it breaks the clip insert/move logic in the track actor + // and it also means the playable range of the track is fixed regarless + // of whether new clips are added to the end which will increase the + // available range. + /*auto source_range = ii->source_range(); if (source_range) self->mail( active_range_atom_v, @@ -433,7 +443,7 @@ void process_item( static_cast(source_range->duration().value()), FrameRate(fps_to_flicks(source_range->duration().rate()))))) .request(actor, infinite) - .receive([=](const JsonStore &) {}, [=](const error &err) {}); + .receive([=](const JsonStore &) {}, [=](const error &err) {});*/ self->mail(insert_item_atom_v, -1, UuidActorVector({UuidActor(uuid, actor)})) .request(parent, infinite) @@ -645,7 +655,8 @@ void process_item( FrameRate(fps_to_flicks(source_range->start_time().rate()))), FrameRateDuration( static_cast(source_range->duration().value()), - FrameRate(fps_to_flicks(source_range->duration().rate()))))) + FrameRate(fps_to_flicks(source_range->duration().rate())))), + false) .request(actor, infinite) .receive([=](const JsonStore &) {}, [=](const error &err) {}); } @@ -1235,7 +1246,7 @@ caf::message_handler TimelineActor::default_event_handler() { return caf::message_handler( { [=](event_atom, item_atom, const Item &) {}, - [=](event_atom, item_atom, const JsonStore &, const bool) {}, + [=](event_atom, item_atom, const JsonStore &, const bool) {} }) .or_else(NotificationHandler::default_event_handler()) .or_else(json_store::JsonStoreHandler::default_event_handler()) @@ -1594,6 +1605,7 @@ caf::message_handler TimelineActor::message_handler() { }, [=](history::undo_atom, const JsonStore &hist) -> result { + auto rp = make_response_promise(); base_.item().undo(hist); @@ -1898,6 +1910,7 @@ caf::message_handler TimelineActor::message_handler() { [=](playlist::add_media_atom, const UuidActorVector &uav, const Uuid &before_uuid) -> result { + auto rp = make_response_promise(); // add internally @@ -2269,6 +2282,53 @@ caf::message_handler TimelineActor::message_handler() { return change_event_group_; }, + [=](playlist::get_media_atom, const bool, const bool) -> result { + // this message signature is used to get only the VISIBLE media actors based + // on the current timeline state - in other words which media you will actually + // see when playing through the whole timeline. + + // clear the focus list, because we want the visible media in the timeline + // when focus mode is not active. + const auto focus_list = base_.focus_list(); + base_.set_focus_list(utility::UuidSet()); + + auto rp = make_response_promise(); + mail(media::get_media_pointers_atom_v, + media::MediaType::MT_IMAGE, + utility::TimeSourceMode::FIXED, + base_.rate()).request(caf::actor_cast(this), infinite).then( + [=](const media::FrameTimeMapPtr &mpts) mutable { + + // restore the focus mode state. + base_.set_focus_list(focus_list); + + // try and make a list of media ids efficiently. + // Timeline could be very long! + utility::UuidSet visible_media; + utility::Uuid current_media; + for (const auto &i : (*mpts)) { + if (i.second && i.second->media_uuid() != current_media) { + current_media = i.second->media_uuid(); + if (visible_media.find(current_media) == visible_media.end()) { + visible_media.insert(current_media); + } + } + } + + utility::UuidActorVector result; + for (const auto & media_id: visible_media) { + auto p = media_actors_.find(media_id); + if (p != media_actors_.end()) { + result.emplace_back(UuidActor(media_id, p->second)); + } + } + rp.deliver(result); + + }, + [=](error &err) mutable { rp.deliver(err); }); + return rp; + }, + [=](playlist::get_media_atom, const bool) -> result> { std::vector actors; // only media actors.. @@ -2608,7 +2668,52 @@ caf::message_handler TimelineActor::message_handler() { // This is required by SubPlayhead actor to make the timeline // playable. return base_.item().get_all_frame_IDs( - media_type, tsm, override_rate, base_.focus_list()); + media_type, tsm, override_rate, base_.audio_mode(), base_.focus_list()); + }, + + [=](serialise_atom, const utility::UuidVector selected_items) -> result { + + // used for copy action for copy/paste of selected items to clipboard + auto rp = make_response_promise(); + serialise_selection_for_clipboard(rp, selected_items); + return rp; + }, + + [=](insert_item_atom, const std::string track_serialisation) -> result { + + // used for paste action for copy/paste of selected items to clipboard. + // See SessionModel::pasteFromClipboard for example use. + auto rp = make_response_promise(); + mail(insert_item_atom_v, track_serialisation, true).request(caf::actor_cast(this), infinite).then( + [=](JsonStore changes) mutable { + + // need this to finalise the changes JsonStore for undo/redo stack. + // TODO: work out how the hell to make this operation undo-able!! + /*auto more = base_.item().refresh(); + + if (not more.is_null()) + changes.insert(changes.begin(), more.begin(), more.end()); + + mail(event_atom_v, item_atom_v, changes, false).send(base_.event_group()); + anon_mail(history::log_atom_v, __sysclock_now(), changes).send(history_); + mail(event_atom_v, change_atom_v).send(this);*/ + rp.deliver(changes); + + }, + [=](caf::error & err) mutable { + rp.deliver(err); + }); + return rp; + + }, + + [=](insert_item_atom, const std::string track_serialisation, bool) -> result { + + // called from message handler immediately above + auto rp = make_response_promise(); + paste_tracks_from_serialisation(rp, track_serialisation); + return rp; + }, [=](serialise_atom) -> result { @@ -2746,7 +2851,27 @@ caf::message_handler TimelineActor::message_handler() { path, data); return rp; - }}; + }, + + [=](audio_mode_atom) -> AudioMode { + return base_.audio_mode(); + }, + + [=](audio_mode_atom, const AudioMode am) { + if (am != base_.audio_mode()) { + base_.set_audio_mode(am); + mail(event_atom_v, audio_mode_atom_v, am).send(base_.event_group()); + } + }, + + [=](event_atom, change_atom, clip_edited_status_atom, const utility::Uuid clip_id, const int edited_status, const utility::Uuid track_id, const utility::Uuid stack_id) { + + // event coming from a clip via track and stack. Send it on (to UI layer) + mail(event_atom_v, change_atom_v, clip_edited_status_atom_v, clip_id, edited_status, track_id, stack_id, base_.item().uuid()).send(base_.event_group()); + + } + + }; } @@ -3710,4 +3835,220 @@ void TimelineActor::duplicate_playhead(caf::actor duplicated_timeline) { } catch (std::exception &e) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, e.what()); } +} + +void TimelineActor::serialise_selection_for_clipboard( + caf::typed_response_promise rp, const utility::UuidVector &selected_items) +{ + + // To provide copy/paste ability between timelines that don't share a parent playlist, we + // need to copy the selected clips/tracks from the timeline. We also need to copy the + // source media for the clips that are being copied so they can be re-made if needed. + + if (base_.item().size() == 0) { + rp.deliver(""); + return; + } + auto stack = base_.item().front(); + + // first step we get a set of duplicated TrackActors that only include selected + // clips too (unless the whole track was selected) + mail(utility::duplicate_atom_v, selected_items) + .request(stack.actor(), infinite).then( + [=](const utility::UuidActorVector &copied_tracks) mutable { + + auto auto_responder = AutoResponder(copied_tracks.size()*2, rp); + auto_responder.result() = JsonStore(R"({"tracks":[], "media":{}})"_json); + + // loop over each track that is being copied + for (const auto &copied_track : copied_tracks) { + + // here we serialise the duplicated tracks. To enacy the 'paste' function + // we deserialise into new TrackActor instances + mail(utility::serialise_atom_v) + .request(copied_track.actor(), infinite) + .then( + [=](const utility::JsonStore &result) mutable { + auto_responder.result()["tracks"].push_back(result); + auto_responder.decrement(); + }, + [=](const caf::error &err) mutable { auto_responder.decrement(err); }); + + // We also fetch all the media items that are referenced by clips in the + // copied tracks: + mail(playlist::get_media_atom_v) + .request(copied_track.actor(), infinite) + .then( + [=](const UuidActorVector &media) mutable { + + send_exit(copied_track.actor(), caf::exit_reason::user_shutdown); + for (const auto &m : media) { + if (auto_responder.result()["media"].contains(to_string(m.uuid()))) { + auto_responder.decrement(); + } else { + auto_responder.increment(); + mail(utility::serialise_atom_v) + .request(m.actor(), infinite) + .then( + [=](const utility::JsonStore &result) mutable { + auto_responder.result()["media"][to_string(m.uuid())] = result; + auto_responder.decrement(); + }, + [=](const caf::error &err) mutable { + auto_responder.decrement(err); + }); + } + } + auto_responder.decrement(); + + }, + [=](const caf::error &err) mutable { auto_responder.decrement(err); }); + + } + }, + [=](const caf::error &err) mutable { rp.deliver(err); }); + +} + +void TimelineActor::paste_tracks_from_serialisation( + caf::typed_response_promise rp, const std::string &track_serialisation) +{ + + try { + + const auto jsn = std::make_shared(nlohmann::json::parse(track_serialisation)); + auto auto_responder = AutoResponder(0, rp); + auto_responder.result() = JsonStore(R"([])"_json); + + auto add_track_to_stack = [=](const UuidActor pasted_track) mutable { + + anon_mail(link_media_atom_v, media_actors_, true).send(pasted_track.actor()); + auto stack = base_.item().front(); + mail(insert_item_atom_v, 0, UuidActorVector({pasted_track})) + .request(stack.actor(), infinite) + .then( + [=](const utility::JsonStore change) mutable { + if (change.is_array()) { + for (const auto &v: change) { + auto_responder.result().push_back(v); + } + } else { + auto_responder.result().push_back(change); + } + auto_responder.decrement(); + }, + [=](caf::error &err) mutable { rp.deliver(err); }); + + }; + + // If we are copy/pasting clips or tracks from the SAME timeline, the media + // for the copied clips will already exist in the timeline. + // For different timelines under the same Playlist, the media might also be + // available already in the destination timeline. + // For different timelines under different playlists, the media for the clips + // will not be available and must be created. + auto add_missing_media_then_add_track = [=](const Item & track_item, const UuidActor pasted_track) mutable { + + auto_responder.increment(); + + UuidActorVector added_media; + UuidUuidMap swapped_media_map; + UuidActorMap added_media_map; + + scoped_actor sys(system()); + // loop over items(clips) in the track + for (const auto &citem : track_item.children()) { + if (citem.item_type() == IT_CLIP) { + // get the media UUID for the clip. + auto media_uuid = citem.prop().value("media_uuid", Uuid()); + + // if the media item is already in the media in this timeline, + // nothing to do (re-link step later will hook it up with the clip) + if (media_actors_.count(media_uuid)) continue; + + if ((*jsn)["media"].contains(to_string(media_uuid))) { + + // Timeline does not include the media that the clip needs. + // Create the MediaActor .... + + // To make things more awkward, it's possible that the media + // that we're creating here exists in another playlist with + // the same UUID, because our copy/paste mechanism is based + // on serialising the media items which preserves the UUID. + + // So, we use the duplicate mechanism which will clone the + // media but use new UUIDs for the media actor and its children. + // Later we need to re-link these into our new track(s). + + auto copied_media = spawn(utility::JsonStore((*jsn)["media"][to_string(media_uuid)])); + auto cloned_media = request_receive( + *sys, copied_media, utility::duplicate_atom_v).second; + swapped_media_map[media_uuid] = cloned_media.uuid(); + added_media_map[cloned_media.uuid()] = cloned_media; + added_media.push_back(cloned_media); + send_exit(copied_media, caf::exit_reason::user_shutdown); + } + } + } + + if (added_media.empty()) { + add_track_to_stack(pasted_track); + return; + } + + mail(playlist::add_media_atom_v, added_media, utility::Uuid()).request( + caf::actor_cast(this), infinite).then( + [=](bool r) mutable { + + // switches out the media referenced by the new track with any new + // media that we had to clone above + mail(playhead::source_atom_v, swapped_media_map, added_media_map).request( + pasted_track.actor(), infinite).then( + [=](bool r) mutable { + + // now we continue with the operation + add_track_to_stack(pasted_track); + + }, + [=](caf::error &err) mutable { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, to_string(err)); + auto_responder.decrement(err); + }); + + + }, + [=](caf::error &err) mutable { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, to_string(err)); + auto_responder.decrement(err); + }); + }; + + if (!(*jsn).contains("tracks") || !(*jsn)["tracks"].is_array()) { + throw std::runtime_error("No timline tracks in pasted content."); + } + + scoped_actor sys(system()); + + for (const auto & track_data: (*jsn)["tracks"]) { + + // here we make a track from the serialised data - the UUIDs for the + // track and it's children come from 'track_data'. + auto copied_track = spawn(JsonStore(track_data)); + + // we duplicate copied_track here - this will ensure unique UUIDs for + // the track and its children + auto cloned_track = request_receive( + *sys, copied_track, utility::duplicate_atom_v); + auto track_item = request_receive(*sys, cloned_track.actor(), item_atom_v); + + add_missing_media_then_add_track(track_item, cloned_track); + + send_exit(copied_track, caf::exit_reason::user_shutdown); + + } + + } catch (const std::exception &err) { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); + rp.deliver(make_error(xstudio_error::error, err.what())); + } } \ No newline at end of file diff --git a/src/timeline/src/track_actor.cpp b/src/timeline/src/track_actor.cpp index c003080c0..3e4b915c4 100644 --- a/src/timeline/src/track_actor.cpp +++ b/src/timeline/src/track_actor.cpp @@ -673,6 +673,13 @@ caf::message_handler TrackActor::message_handler() { return rp; }, + [=](event_atom, change_atom, clip_edited_status_atom, const utility::Uuid clip_id, const int edited_status) { + + // event coming from clip. Pass up to stack + mail(event_atom_v, change_atom_v, clip_edited_status_atom_v, clip_id, edited_status, base_.item().uuid()).send(base_.event_group()); + + }, + [=](event_atom, change_atom) { // update_edit_list_ = true; mail(event_atom_v, change_atom_v).send(base_.event_group()); @@ -708,6 +715,13 @@ caf::message_handler TrackActor::message_handler() { return rp; }, + [=](duplicate_atom, const utility::UuidVector &selection_for_duplication) -> result { + + auto rp = make_response_promise(); + do_partial_duplication(rp, selection_for_duplication); + return rp; + }, + [=](playhead::source_atom, const UuidUuidMap &swap, const UuidActorMap &media) -> result { @@ -762,6 +776,28 @@ caf::message_handler TrackActor::message_handler() { return result(jsn); }, + [=](playlist::get_media_atom) -> result { + + // get all media actors for children of this track. + auto rp = make_response_promise(); + auto ar = utility::AutoResponder(0, rp); + for (const auto &i : base_.item().children()) { + if (i.item_type() == IT_CLIP) { + ar.increment(); + mail(playlist::get_media_atom_v, true) + .request(i.actor(), infinite) + .then( + [=](const UuidUuidActor &ua) mutable { + ar.result().push_back(ua.second); + ar.decrement(); + }, + [=](error &err) mutable { ar.decrement(err); }); + } + } + + return rp; + }, + [=](media::get_media_pointers_atom atom, const media::MediaType media_type, const TimeSourceMode tsm, @@ -1727,4 +1763,73 @@ void TrackActor::merge_gaps(caf::typed_response_promise rp) { mail(event_atom_v, item_atom_v, changes, false).send(base_.event_group()); rp.deliver(changes); -} \ No newline at end of file +} + +void TrackActor::do_partial_duplication( + caf::typed_response_promise rp, const utility::UuidVector &selection_for_duplication) { + + // the goal of this is to duplicate the complete track if it's in the selection, otherwiser + // we duplicate the track and only it's children that are in the selection. + // For clips not in the selection, we create a gap to fill the space. + + bool in_selection = false; + for (const auto &i : selection_for_duplication) { + if (base_.item().uuid() == i) { + // track is in selection, duplicate whole track. + rp.delegate(caf::actor_cast(this), duplicate_atom_v); + return; + } + auto item = find_item(base_.item().children(), i); + if (item) { + in_selection = true; + } + } + + if (not in_selection) { + // neither track or any of its children are in selection, don't duplicate. + rp.deliver(UuidActor()); + } else { + + JsonStore jsn; + auto dup = base_.duplicate(); + dup.item().clear(); + + jsn["base"] = dup.serialise(); + jsn["actors"] = {}; + auto actor = spawn(jsn); + + if (actors_.empty()) { + rp.deliver(UuidActor(dup.uuid(), actor)); + } else { + // duplicate all children and relink against items. + scoped_actor sys{system()}; + + FrameRateDuration gap_duration; + gap_duration.set_rate(base_.item().rate()); + + for (const auto &i : base_.children()) { + + if (std::find(selection_for_duplication.begin(), selection_for_duplication.end(), i.uuid()) == selection_for_duplication.end()) { + // child is not in selection ... add to the gap duration and continue. + gap_duration.set_duration(gap_duration.duration() + i.trimmed_duration()); + continue; + } + + if (gap_duration.duration() > timebase::k_flicks_zero_seconds) { + // make the required gap actor and insert it before the next child. + utility::Uuid gap_uuid = utility::Uuid::generate(); + auto gap_actor = spawn("Gap", gap_duration, gap_uuid); + auto ua = UuidActor(gap_uuid, gap_actor); + request_receive( + *sys, actor, insert_item_atom_v, -1, UuidActorVector({ua})); + gap_duration.set_duration(FrameRate()); + } + auto ua = + request_receive(*sys, actors_[i.uuid()], duplicate_atom_v); + request_receive( + *sys, actor, insert_item_atom_v, -1, UuidActorVector({ua})); + } + rp.deliver(UuidActor(dup.uuid(), actor)); + } + } +} diff --git a/src/ui/canvas/src/stroke.cpp b/src/ui/canvas/src/stroke.cpp index 474f40dad..0f914591d 100644 --- a/src/ui/canvas/src/stroke.cpp +++ b/src/ui/canvas/src/stroke.cpp @@ -259,14 +259,8 @@ void Stroke::add_point(const Imath::V2f &pt, float size_pressure, float opacity_ } bool Stroke::fade(const float fade_amount) { - bool is_invisible = true; - for (auto &point : _points) { - point.opacity_pressure = std::max(0.0f, point.opacity_pressure - fade_amount); - if (point.opacity_pressure) - is_invisible = false; // at least one point has non-zero opacity ... don't erase - } - update_hash(); - return is_invisible; + _fade_factor = std::max(0.0f, _fade_factor - fade_amount); + return _fade_factor == 0.0f; } void xstudio::ui::canvas::from_json(const nlohmann::json &j, Stroke &s) { diff --git a/src/ui/model_data/src/model_data_actor.cpp b/src/ui/model_data/src/model_data_actor.cpp index 5dca99ed8..da007df57 100644 --- a/src/ui/model_data/src/model_data_actor.cpp +++ b/src/ui/model_data/src/model_data_actor.cpp @@ -134,36 +134,32 @@ GlobalUIModelData::GlobalUIModelData(caf::actor_config &cfg) : caf::event_based_ } set_down_handler([=](down_msg &msg) { - // has a client exited? - for (auto p : models_) { - auto c = p.second->clients_.begin(); - while (c != p.second->clients_.end()) { - if (caf::actor_cast(*c) == msg.source) { - c = p.second->clients_.erase(c); - } else { - c++; - } - } - std::vector> dead_menu_nodes; - auto w = p.second->menu_watchers_.begin(); - while (w != p.second->menu_watchers_.end()) { - auto watcher = w->second.begin(); - while (watcher != w->second.end()) { - if (caf::actor_cast(*watcher) == msg.source) { - watcher = w->second.erase(watcher); - } else { - watcher++; - } - } - if (w->second.empty()) { - dead_menu_nodes.emplace_back(p.first, w->first); - } - w++; - } - for (const auto &d : dead_menu_nodes) { - remove_node(d.first, d.second); - } - } + + // Handling the exit of an actor that we are monitoring is crucial. + // There are two cases - + + // 1) an actor that is adding menu items to a menu model and wants + // to know when the user has clicked on a menu item that it + // created (a watcher) - eg. qml XsMenuModelItem(cpp = UIModelData) + // 2) An actor that is a 'client' of a menu model, in other words a + // UI component that exposes the menu data so that the graphical + // menu items can be created in the UI - e.g. qml XsMenusModel( cpp = MenusModelData) + + // If an XsMenuModelItem is destroyed in the UI, we must remove its a + // associated entry in the menu model here - this allows us to make + // fully dynamic menus driven by Repeaters and other logic in the QML + // layer. When we have menus built in this way by a Repeater, we may + // get many XsMenuModelItem being destroyed at once when a Repeater + // re-evaluates its content. This results in many down_msg being + // received here in quick succession. To make it more efficient, we + // batch the handling of these messages by adding the address of the + // actor that's existed to dead_clients_ and then sending a timed + // message to ourselves to do the cleanup. + cleanup_timepoint_ = utility::clock::now(); + exiting_clients_and_watchers_.insert(msg.source); + mail(cleanup_timepoint_).delay(std::chrono::milliseconds(50)).send(this); + + }); behavior_.assign( @@ -182,6 +178,8 @@ GlobalUIModelData::GlobalUIModelData(caf::actor_config &cfg) : caf::event_based_ register_model(model_name, model_data, client); return model_data_as_json(model_name); } catch (std::exception &e) { + spdlog::warn( + "GlobalUIModelData register '{}' failed: {}", model_name, e.what()); return caf::make_error(xstudio_error::error, e.what()); } }, @@ -382,7 +380,8 @@ GlobalUIModelData::GlobalUIModelData(caf::actor_config &cfg) : caf::event_based_ for (const auto &model_name : models_to_be_fully_broadcasted_) { const auto model_data = model_data_as_json(model_name); - for (auto &client : models_[model_name]->clients_) { + for (auto &c : models_[model_name]->clients_) { + auto client = caf::actor_cast(c); if (client) anon_send( client, @@ -412,6 +411,11 @@ GlobalUIModelData::GlobalUIModelData(caf::actor_config &cfg) : caf::event_based_ if (pressed) hotkey_pressed(kotkey_uuid, context, window); }, + [=](const utility::clock::time_point &cleanup_tp) + { + if (cleanup_tp != cleanup_timepoint_) return; + do_cleanup_for_exited_clients(); + }, [=](const caf::error &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, to_string(err)); }, @@ -461,7 +465,8 @@ void GlobalUIModelData::set_data( if (changed) { //} && !role.empty()) { if (!role.empty()) { - for (auto &client : models_[model_name]->clients_) { + for (auto &c : models_[model_name]->clients_) { + auto client = caf::actor_cast(c); if (client) mail( utility::event_atom_v, @@ -474,7 +479,8 @@ void GlobalUIModelData::set_data( .send(client); } } else { - for (auto &client : models_[model_name]->clients_) { + for (auto &c : models_[model_name]->clients_) { + auto client = caf::actor_cast(c); if (client) { mail( utility::event_atom_v, set_node_data_atom_v, model_name, path, data) @@ -490,7 +496,8 @@ void GlobalUIModelData::set_data( auto p = models_[model_name]->menu_watchers_.find(uuid); if (p != models_[model_name]->menu_watchers_.end()) { auto &watchers = p->second; - for (auto watcher : watchers) { + for (auto w : watchers) { + auto watcher = caf::actor_cast(w); if (watcher) mail( utility::event_atom_v, @@ -558,7 +565,8 @@ void GlobalUIModelData::set_data( std::string path = path_from_node(node); - for (auto &client : models_[model_name]->clients_) { + for (auto &c : models_[model_name]->clients_) { + auto client = caf::actor_cast(c); if (client != setter) mail( utility::event_atom_v, @@ -578,7 +586,8 @@ void GlobalUIModelData::set_data( auto p = models_[model_name]->menu_watchers_.find(uuid); if (p != models_[model_name]->menu_watchers_.end()) { auto &watchers = p->second; - for (auto watcher : watchers) { + for (auto w : watchers) { + auto watcher = caf::actor_cast(w); // we don't notify the thing that is setting this data // as it will update it's local data if (watcher != setter) @@ -621,7 +630,7 @@ void GlobalUIModelData::insert_attribute_data_into_model( } } if (!already_a_client) { - p->second->clients_.push_back(client); + p->second->clients_.insert(caf::actor_cast(client)); monitor(client); } @@ -631,6 +640,8 @@ void GlobalUIModelData::insert_attribute_data_into_model( monitor(client); } + register_client_with_model(client, model_name); + utility::JsonTree *parent_node = &(models_[model_name]->data_); try { auto pnode = find_node_matching_string_field( @@ -709,7 +720,7 @@ void GlobalUIModelData::remove_attribute_data_from_model( utility::JsonTree *model_data = &(models_[model_name]->data_); auto &clients = models_[model_name]->clients_; for (auto c = clients.begin(); c != clients.end(); ++c) { - if (*c == client) { + if (*c == caf::actor_cast(client)) { clients.erase(c); break; } @@ -766,7 +777,7 @@ void GlobalUIModelData::register_model( } if (!already_a_client) { - p->second->clients_.push_back(client); + p->second->clients_.insert(caf::actor_cast(client)); monitor(client); } @@ -806,34 +817,11 @@ void GlobalUIModelData::register_model( } else { auto new_model_data = std::make_shared(model_name, model_data, client); - - // If we are adding a new model - check if there is a model with a wildcard - // If there is, we duplicate that wildcard model data into the new - // model that we're adding as a starting point. - // - // This specifically means that if there is a C++ plugin that has created - // menu items in a model named 'MyModel*' then any models created - // in the UI layer called 'MyModel' will include the menu - // items created in the backend. - for (auto &p : models_) { - - if (p.first.find("*") == p.first.size() - 1) { - - std::string match_name(p.first, 0, (p.first.size() - 1)); - if (model_name.find(match_name) == 0) { - new_model_data->data_ = p.second->data_; - new_model_data->clients_.insert( - new_model_data->clients_.begin(), - p.second->clients_.begin(), - p.second->clients_.end()); - new_model_data->menu_watchers_ = p.second->menu_watchers_; - } - } - } - models_[model_name] = new_model_data; monitor(client); } + + register_client_with_model(client, model_name); } utility::JsonStore GlobalUIModelData::model_data_as_json(const std::string &model_name) const { @@ -932,8 +920,9 @@ void GlobalUIModelData::insert_rows( } // caf::scoped_actor sys(system()); - for (auto &client : models_[model_name]->clients_) { + for (auto &c : models_[model_name]->clients_) { + auto client = caf::actor_cast(c); // if we know 'requester', then the requester does not want to // get the change event as it has already updated its local model if (client && client != requester) { @@ -977,7 +966,10 @@ void GlobalUIModelData::remove_rows( const auto model_data_json = model_data_as_json(model_name); caf::scoped_actor sys(system()); - for (auto &client : models_[model_name]->clients_) { + for (auto &c : models_[model_name]->clients_) { + + auto client = caf::actor_cast(c); + // if we know 'requester', then the requester does not want to // get the change event as it has already updated its local model if (client != requester) { @@ -1070,7 +1062,8 @@ void GlobalUIModelData::node_activated( auto p = models_[model_name]->menu_watchers_.find(uuid); if (p != models_[model_name]->menu_watchers_.end()) { auto &watchers = p->second; - for (auto watcher : watchers) { + for (auto w : watchers) { + auto watcher = caf::actor_cast(w); mail( utility::event_atom_v, menu_node_activated_atom_v, @@ -1227,7 +1220,8 @@ void GlobalUIModelData::insert_into_menu_model( for (auto it = new_data.begin(); it != new_data.end(); it++) { if (!old_data.contains(it.key()) || old_data[it.key()] != it.value()) { - for (auto &client : models_[model_name]->clients_) { + for (auto &c : models_[model_name]->clients_) { + auto client = caf::actor_cast(c); if (client) mail( utility::event_atom_v, @@ -1249,9 +1243,10 @@ void GlobalUIModelData::insert_into_menu_model( do_ordering(menu_model_data->parent()); auto &watchers = models_[model_name]->menu_watchers_[menu_uuid]; - if (std::find(watchers.begin(), watchers.end(), watcher) == watchers.end()) { - watchers.push_back(watcher); + if (watchers.find(caf::actor_cast(watcher)) == watchers.end()) { + watchers.insert(caf::actor_cast(watcher)); } + register_watcher_with_model(watcher, model_name); monitor(watcher); if (!already_defined || needs_reorering) @@ -1559,7 +1554,8 @@ void GlobalUIModelData::reset_model( check_model_is_registered(model_name, true); models_[model_name]->data_ = utility::json_to_tree(data, "children"); const auto model_data = model_data_as_json(model_name); - for (auto &client : models_[model_name]->clients_) { + for (auto &c : models_[model_name]->clients_) { + auto client = caf::actor_cast(c); if (client == excluded_client) continue; anon_send(client, utility::event_atom_v, model_data_atom_v, model_name, model_data); @@ -1571,6 +1567,80 @@ void GlobalUIModelData::reset_model( } } +void GlobalUIModelData::register_watcher_with_model(caf::actor watcher, const std::string &model_name) +{ + if (models_.find(model_name) == models_.end()) { + spdlog::warn( + "GlobalUIModelData register_watcher_with_model: '{}' is not a registered model", + model_name); + return; + } + models_per_watcher_[caf::actor_cast(watcher)].insert(models_[model_name]); +} + +void GlobalUIModelData::register_client_with_model(caf::actor client, const std::string &model_name) +{ + if (models_.find(model_name) == models_.end()) { + spdlog::warn( + "GlobalUIModelData register_client_with_model: '{}' is not a registered model", + model_name); + return; + } + models_per_client_[caf::actor_cast(client)].insert(models_[model_name]); +} + +void GlobalUIModelData::do_cleanup_for_exited_clients() { + + // has a client exited? + for (auto exiting_actor : exiting_clients_and_watchers_) { + + auto m = models_per_client_.find(exiting_actor); + if (m != models_per_client_.end()) { + + for (auto &model: m->second) { + auto cc = model->clients_.find(exiting_actor); + if (cc != model->clients_.end()) { + model->clients_.erase(cc); + } + } + } + + m = models_per_watcher_.find(exiting_actor); + if (m != models_per_watcher_.end()) { + + for (auto &model: m->second) { + + std::vector> dead_menu_nodes; + + // menu_watchers_ is a map of menu item IDs and actors that are + // watching that menu item. We erase entries for the actor that + // is exiting. If we do an erase and then there are no actors + // watching the menu item, we assume the menu item is no longer + // needed and we erase it from the model. This mechanism allows + // us to add and remove items from menus in xSTUDIO dynamically, + // (using XsMenuModelItem and a Repeater, say) + auto w = model->menu_watchers_.begin(); + while (w != model->menu_watchers_.end()) { + auto ww = w->second.find(exiting_actor); + if (ww != w->second.end()) { + w->second.erase(ww); + if (w->second.empty()) { + dead_menu_nodes.emplace_back(model->name_, w->first); + } + } + w++; + } + for (const auto &d : dead_menu_nodes) { + remove_node(d.first, d.second); + } + + } + } + } + exiting_clients_and_watchers_.clear(); + +} + #ifdef __GNUC__ // Check if GCC compiler is being used #pragma GCC diagnostic pop #endif \ No newline at end of file diff --git a/src/ui/opengl/src/opengl_stroke_renderer.cpp b/src/ui/opengl/src/opengl_stroke_renderer.cpp index 99e194c19..dbcb4c6d9 100644 --- a/src/ui/opengl/src/opengl_stroke_renderer.cpp +++ b/src/ui/opengl/src/opengl_stroke_renderer.cpp @@ -175,7 +175,7 @@ void OpenGLStrokeRenderer::render_single_stroke_pass1( shader_params["to_canvas"] = transform_window_to_viewport_space; shader_params["soft_edge"] = get_soft_edge(stroke, viewport_du_dx); shader_params["z_adjust"] = depth; - shader_params["brush_opacity"] = stroke->opacity(); + shader_params["brush_opacity"] = stroke->opacity() * stroke->fade_factor(); shader_params["thickness"] = stroke->thickness(); shader_params["just_black"] = 1.0f; // renders stroke quads as black stroke_shader_->set_shader_parameters(shader_params); @@ -227,7 +227,7 @@ void OpenGLStrokeRenderer::render_single_stroke_pass2( shader_params["soft_edge"] = get_soft_edge(stroke, viewport_du_dx); shader_params["z_adjust"] = depth; shader_params["brush_colour"] = stroke->colour(); - shader_params["brush_opacity"] = stroke->opacity(); + shader_params["brush_opacity"] = stroke->opacity() * stroke->fade_factor(); shader_params["thickness"] = stroke->thickness(); shader_params["offscreenTexture"] = 11; shader_params["blend_mode"] = 0; diff --git a/src/ui/qml/embedded_python/src/embedded_python_ui.cpp b/src/ui/qml/embedded_python/src/embedded_python_ui.cpp index 9d2ad2cd6..9debcfc58 100644 --- a/src/ui/qml/embedded_python/src/embedded_python_ui.cpp +++ b/src/ui/qml/embedded_python/src/embedded_python_ui.cpp @@ -23,6 +23,7 @@ EmbeddedPythonUI::EmbeddedPythonUI(QObject *parent) : super(parent) { setRoleNames( std::vector({ "nameRole", + "menuHotKeyRole", "menuPathRole", "scriptPathRole", "snippetTypeRole", @@ -295,6 +296,11 @@ QVariant EmbeddedPythonUI::data(const QModelIndex &index, int role) const { result = QString::fromStdString(j.at("snippet_type")); break; + case Roles::menuHotKeyRole: + if (j.count("menu_hotkey")) + result = QString::fromStdString(j.at("menu_hotkey")); + break; + case Roles::menuPathRole: if (j.count("menu_path")) result = QString::fromStdString(j.at("menu_path")); diff --git a/src/ui/qml/helper/src/helper_ui.cpp b/src/ui/qml/helper/src/helper_ui.cpp index d4f58fb4b..8ff3b6624 100644 --- a/src/ui/qml/helper/src/helper_ui.cpp +++ b/src/ui/qml/helper/src/helper_ui.cpp @@ -611,7 +611,7 @@ QString Helpers::readFile(const QUrl &url) const { } QFuture Helpers::pythonAsyncCallback( - const QString pluginName, const QString methodName, QVariant args) { + const QString pluginName, const QString methodName, QVariant args) const { // It looks like if we have a QJSValue and we try and access from another thread we can get // a crash! So we 'bake' it here to QVariant @@ -704,6 +704,8 @@ QObject *Helpers::contextPanel(QObject *obj) const { QObject *cobj = c->contextObject(); if (cobj && cobj->objectName() == "XsPanelParent") { return pobj; + } else if (cobj && cobj->objectName() =="XsTimelinePanel") { + return cobj; } pobj = cobj; c = c->parentContext(); diff --git a/src/ui/qml/helper/src/model_data_ui.cpp b/src/ui/qml/helper/src/model_data_ui.cpp index 1c3dca5f1..fb583a782 100644 --- a/src/ui/qml/helper/src/model_data_ui.cpp +++ b/src/ui/qml/helper/src/model_data_ui.cpp @@ -86,13 +86,25 @@ void UIModelData::setModelDataName(QString name) { // we send empty data to 'register' but if the model already exists // we'll be sent back what's already in the model if (data_preference_path_.empty()) { - auto data = request_receive( - *sys, - central_models_data_actor_, - ui::model_data::register_model_data_atom_v, - model_name_, - utility::JsonStore(nlohmann::json::parse("{}")), - as_actor()); + utility::JsonStore data; + try { + data = request_receive( + *sys, + central_models_data_actor_, + ui::model_data::register_model_data_atom_v, + model_name_, + utility::JsonStore(nlohmann::json::parse("{}")), + as_actor()); + } catch (const XStudioError &e) { + // This runs inside a QML binding update on the Qt thread: an + // escaping exception terminates the process. Log and leave the + // model empty instead. + spdlog::warn( + "UIModelData::setModelDataName('{}') registration failed: {}", + model_name_, + e.what()); + return; + } // now we update with the returned model data setModelData(data); diff --git a/src/ui/qml/session/src/caf_response_ui.cpp b/src/ui/qml/session/src/caf_response_ui.cpp index 106528675..ffe4bade5 100644 --- a/src/ui/qml/session/src/caf_response_ui.cpp +++ b/src/ui/qml/session/src/caf_response_ui.cpp @@ -61,6 +61,17 @@ class CafRequest : public ControllableJob> { requestNotificationRole(type, sys, system_, result); break; + case SessionModel::Roles::audioModeRole: + if (type == "Timeline") { + + auto data = request_receive( + *sys, + actorFromString(system_, json_.at("actor")), + timeline::audio_mode_atom_v); + + result[role_] = QStringFromStd(json(static_cast(data)).dump()); + } + break; case SessionModel::Roles::bitDepthRole: case SessionModel::Roles::resolutionRole: case SessionModel::Roles::formatRole: @@ -107,6 +118,16 @@ class CafRequest : public ControllableJob> { } break; + case SessionModel::Roles::clipEditedStatusRole: + if (type == "Clip") { + auto data = request_receive( + *sys, + actorFromString(system_, json_.at("actor")), + timeline::clip_edited_status_atom_v); + result[role_] = QString("%1").arg(data); + } + break; + case SessionModel::timecodeAsFramesRole: case SessionModel::Roles::pathShakeRole: case SessionModel::Roles::pathRole: diff --git a/src/ui/qml/session/src/session_model_core_ui.cpp b/src/ui/qml/session/src/session_model_core_ui.cpp index 2f042ab3b..bb9308a7a 100644 --- a/src/ui/qml/session/src/session_model_core_ui.cpp +++ b/src/ui/qml/session/src/session_model_core_ui.cpp @@ -26,12 +26,14 @@ SessionModel::SessionModel(QObject *parent) : super(parent) { {"actorRole"}, {"actorUuidRole"}, {"audioActorUuidRole"}, + {"audioModeRole"}, {"availableDurationRole"}, {"availableStartRole"}, {"bitDepthRole"}, {"bookmarkUuidsRole"}, {"busyRole"}, {"clipMediaUuidRole"}, + {"clipEditedStatusRole"}, {"containerUuidRole"}, {"enabledRole"}, {"errorRole"}, @@ -547,6 +549,20 @@ QVariant SessionModel::data(const QModelIndex &index, int role) const { } break; + case Roles::audioModeRole: + if (j.count("audio_mode")) { + if (j.at("audio_mode").is_null()) { + requestData( + QVariant::fromValue(QUuidFromUuid(j.at("actor_uuid"))), + actorUuidRole, + getPlaylistIndex(index), + index, + role); + } else { + result = QVariant::fromValue(j.at("audio_mode").get()); + } + } + break; case Roles::pixelAspectRole: if (j.count("pixel_aspect")) { if (j.at("pixel_aspect").is_null()) { @@ -793,6 +809,21 @@ QVariant SessionModel::data(const QModelIndex &index, int role) const { } break; + case Roles::clipEditedStatusRole: + if (j.count("clip_edited_status")) { + if (j.at("clip_edited_status").is_null()) { + requestData( + QVariant::fromValue(QUuidFromUuid(j.at("id"))), + idRole, + index, + index, + role); + } else { + result = QVariant::fromValue(j.at("clip_edited_status").get()); + } + } + break; + case Roles::flagColourRole: if (j.count("placeholder")) { result = QString(""); @@ -947,7 +978,6 @@ QVariant SessionModel::data(const QModelIndex &index, int role) const { } break; - case Roles::activeDurationRole: if (j.count("active_range")) { if (j.at("active_range").is_object()) { @@ -1115,6 +1145,10 @@ bool SessionModel::setData(const QModelIndex &index, const QVariant &qvalue, int result = true; break; + case Roles::clipEditedStatusRole: + result = false; // read-only data + break; + case activeStartRole: if (j.count("active_range")) { auto fr = FrameRange(); @@ -1138,8 +1172,9 @@ bool SessionModel::setData(const QModelIndex &index, const QVariant &qvalue, int // probably pointless, as this will trigger from the backend update roles.push_back(trimmedStartRole); roles.push_back(activeRangeValidRole); - if (actor) + if (actor) { anon_mail(timeline::active_range_atom_v, fr).send(actor); + } } } break; @@ -1194,8 +1229,9 @@ bool SessionModel::setData(const QModelIndex &index, const QVariant &qvalue, int // probably pointless, as this will trigger from the backend update roles.push_back(trimmedDurationRole); roles.push_back(activeRangeValidRole); - if (actor) + if (actor) { anon_mail(timeline::active_range_atom_v, fr).send(actor); + } } } break; @@ -1368,6 +1404,15 @@ bool SessionModel::setData(const QModelIndex &index, const QVariant &qvalue, int } } break; + case audioModeRole: { + if (type == "Timeline") { + anon_mail(timeline::audio_mode_atom_v, static_cast(value.get())) + .send(actor); + j["audio_mode"] = value; + result = true; + } + } break; + case rotationRole: { if (type == "Media") { if (index.isValid()) { diff --git a/src/ui/qml/session/src/session_model_handler_ui.cpp b/src/ui/qml/session/src/session_model_handler_ui.cpp index a6567fb46..ddfcb6d6e 100644 --- a/src/ui/qml/session/src/session_model_handler_ui.cpp +++ b/src/ui/qml/session/src/session_model_handler_ui.cpp @@ -163,6 +163,29 @@ void SessionModel::init(caf::actor_system &_system) { } }, + [=](utility::event_atom, + utility::change_atom, + timeline::clip_edited_status_atom, + const int status) { + try { + auto src = caf::actor_cast(self()->current_sender()); + auto src_str = actorToString(system(), src); + // src_str); search from index.. + receivedData( + json(src_str), actorRole, QModelIndex(), clipEditedStatusRole, json(status)); + } catch (...) { + } + }, + + [=](utility::event_atom, utility::notification_atom, const caf::actor &src, const JsonStore &digest) { + try { + auto src_str = actorToString(system(), src); + receivedData( + json(src_str), actorRole, QModelIndex(), notificationRole, digest); + } catch (...) { + } + }, + [=](utility::event_atom, utility::notification_atom, const JsonStore &digest) { try { auto src = caf::actor_cast(self()->current_sender()); @@ -454,12 +477,9 @@ void SessionModel::init(caf::actor_system &_system) { [=](utility::event_atom, ui::viewport::viewport_playhead_atom, - utility::Uuid playhead_uuid) { - // this comes from the backend SessionActor - if (QUuidFromUuid(playhead_uuid) != on_screen_playhead_uuid_) { - on_screen_playhead_uuid_ = QUuidFromUuid(playhead_uuid); - emit onScreenPlayheadUuidChanged(); - } + const utility::Uuid &uuid) { + on_screen_playhead_uuid_ = QUuidFromUuid(uuid); + emit onScreenPlayheadUuidChanged(); }, [=](utility::event_atom, @@ -501,6 +521,34 @@ void SessionModel::init(caf::actor_system &_system) { // spdlog::warn("{}", data_.dump(2)); }, + [=](utility::event_atom, timeline::audio_mode_atom, const timeline::AudioMode am) { + // direct from timeline. The timeline's parent playlist forwards the same message with + // additional info - see handler immediately below + }, + + [=](utility::event_atom, timeline::audio_mode_atom, timeline::AudioMode audioMode, caf::actor timeline_actor, caf::actor playlist_actor) { + + // I've got to admit this stuff is a bit insane. We need to update a value in the model (the timeline + // audio mode). We need to find the timeline in the model (ideally without recursively searching + // then entire thing - hence finding playlist first which is not that deep in the model). + auto playlist_actor_str = actorToString(system(), playlist_actor); + auto playlist_actor_qstr = QVariant::fromValue(QStringFromStd(playlist_actor_str)); + auto playlist_index = searchRecursive(playlist_actor_qstr, actorRole, QModelIndex(), 0, 2); + if (playlist_index.isValid()) { + + auto timeline_actor_str = actorToString(system(), timeline_actor); + auto timeline_actor_qstr = QVariant::fromValue(QStringFromStd(timeline_actor_str)); + auto timeline_index = searchRecursive(timeline_actor_qstr, actorRole, playlist_index, 0, 2); + if (timeline_index.isValid()) { + + nlohmann::json &j = indexToData(timeline_index); + if (!j.contains("audio_mode") || j["audio_mode"].is_null() || j["audio_mode"].get() != static_cast(audioMode)) { + j["audio_mode"] = static_cast(audioMode); + emit dataChanged(timeline_index, timeline_index, QVector({audioModeRole})); + } + } + } + }, [=](utility::event_atom, playlist::move_container_atom, @@ -1163,7 +1211,69 @@ void SessionModel::init(caf::actor_system &_system) { } }, + [=](xstudio::utility::event_atom atom1, + xstudio::utility::change_atom atom2, + xstudio::timeline::clip_edited_status_atom atom3, + const utility::Uuid &clip_id, + const int status, + const utility::Uuid &track_id) { + // we're subscribed to events from tracks, if I understand correctly + // However this message gets forwarded again from the timeline + // so we handle that re-forwarded message below. + }, + + [=](xstudio::utility::event_atom atom1, + xstudio::utility::change_atom atom2, + xstudio::timeline::clip_edited_status_atom atom3, + const utility::Uuid &clip_id, + const int status, + const utility::Uuid &track_id, + const utility::Uuid &stack_id, + const utility::Uuid &timeline_id) { + // coming from timeline (via playlist) to session. + // Yes these UI updates from the backend are a bit of a mess, but it works for now. + QModelIndex timeline_index = searchRecursive( + QVariant::fromValue(QUuidFromUuid(timeline_id)), idRole, QModelIndex(), 0, -1); + if (timeline_index.isValid()) { + if (timeline_index.isValid()) { + QModelIndex stack_index = searchRecursive( + QVariant::fromValue(QUuidFromUuid(stack_id)), + idRole, + timeline_index, + 0, + 1); + if (stack_index.isValid()) { + + QModelIndex track_index = searchRecursive( + QVariant::fromValue(QUuidFromUuid(track_id)), + idRole, + stack_index, + 0, + 1); + if (track_index.isValid()) { + + QModelIndex clip_index = searchRecursive( + QVariant::fromValue(QUuidFromUuid(clip_id)), + idRole, + track_index, + 0, + 1); + if (clip_index.isValid()) { + + nlohmann::json &j = indexToData(clip_index); + j["clip_edited_status"] = status; + emit dataChanged( + clip_index, + clip_index, + QVector({clipEditedStatusRole})); + } + } + } + } + } + }, + [=](broadcast::broadcast_down_atom, const caf::actor_addr &) {}, - [=](caf::message) { spdlog::warn("Unexpected message"); }}; + [=](caf::message msg) { spdlog::warn("Unexpected message: {}", to_string(msg)); }}; }); } diff --git a/src/ui/qml/session/src/session_model_methods_ui.cpp b/src/ui/qml/session/src/session_model_methods_ui.cpp index 686d5d80f..081e88713 100644 --- a/src/ui/qml/session/src/session_model_methods_ui.cpp +++ b/src/ui/qml/session/src/session_model_methods_ui.cpp @@ -44,16 +44,28 @@ QString SessionModel::getNextName(const QString &nameTemplate) const { return result; } -void SessionModel::setSessionSelection(const QModelIndexList &indexes) const { +void SessionModel::setSessionSelection(const QModelIndexList &indexes) { try { UuidActorVector selection; + QModelIndexList timelines; for (auto &i : indexes) { selection.emplace_back(UuidActor( UuidFromQUuid(i.data(actorUuidRole).toUuid()), actorFromQString(system(), i.data(actorRole).toString()))); + + if (i.isValid() && i.data(typeRole) == QString("Timeline")) { + timelines.push_back(i); + } + } anon_mail(timeline::item_selection_atom_v, selection).send(session_actor_); + + if (timelines != multi_select_timeline_indeces_) { + multi_select_timeline_indeces_ = timelines; + emit selectedTimelinesIndecesChanged(); + } + } catch (const std::exception &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); } @@ -238,6 +250,10 @@ void SessionModel::updateCurrentMediaContainerIndexFromBackend() { if (r != current_playlist_index_) { current_playlist_index_ = r; + if (data(current_playlist_index_, typeRole) == QString("Timeline")) { + last_timeline_index_ = current_playlist_index_; + emit lastTimelineIndexChanged(); + } emit currentMediaContainerChanged(); } diff --git a/src/ui/qml/session/src/session_model_timeline_ui.cpp b/src/ui/qml/session/src/session_model_timeline_ui.cpp index 1199eeb4c..3f60cef3f 100644 --- a/src/ui/qml/session/src/session_model_timeline_ui.cpp +++ b/src/ui/qml/session/src/session_model_timeline_ui.cpp @@ -374,100 +374,24 @@ int SessionModel::getTimelineFrameFromClip( return result; } -int SessionModel::getNextTimelineClipFrame(const QModelIndex &timelineIndex, const int frame) { - auto result = frame; +void SessionModel::jumpToNextClip(const QModelIndex &timelineIndex, const bool forwards) { - auto tactor = actorFromQString(system(), timelineIndex.data(actorRole).toString()); - if (timeline_lookup_.count(tactor)) { - auto &item = timeline_lookup_.at(tactor); - auto frstart = FrameRate(item.rate().to_flicks() * frame); - auto frdur = FrameRate( - item.front().trimmed_duration().to_flicks() - - (frstart.to_flicks() - item.front().trimmed_start().to_flicks())); + try { - scoped_actor sys{system()}; - try { - auto ri = request_receive>>( - *sys, tactor, timeline::bake_atom_v, frstart, frdur); - - if (not ri.empty()) { - auto fuuid = Uuid(); - if (ri.front()) - fuuid = ri.front()->first.uuid(); - // scan.. - auto diff = FrameRate(); - for (const auto &i : ri) { - if ((fuuid.is_null() and i) or (not fuuid.is_null() and not i) or - (not fuuid.is_null() and i and fuuid != i->first.uuid())) { - - result += diff / item.rate().to_flicks(); - break; - } - diff += item.rate(); - } - } - } catch (...) { + scoped_actor sys(system()); + auto tactor = actorFromQString(system(), timelineIndex.data(actorRole).toString()); + auto playhead = request_receive( + *sys, tactor, playlist::get_playhead_atom_v); + if (playhead.actor()) { + anon_mail(playhead::skip_to_clip_atom_v, forwards).send(playhead.actor()); } - } - - return result; -} -int SessionModel::getPreviousTimelineClipFrame( - const QModelIndex &timelineIndex, const int frame) { - auto result = 0; - - auto tactor = actorFromQString(system(), timelineIndex.data(actorRole).toString()); - if (timeline_lookup_.count(tactor)) { - auto &item = timeline_lookup_.at(tactor); - auto frstart = item.front().trimmed_start(); - auto frdur = FrameRate( - FrameRate(item.rate().to_flicks() * frame).to_flicks() - frstart.to_flicks()); - - scoped_actor sys{system()}; - try { - auto ri = request_receive>>( - *sys, tactor, timeline::bake_atom_v, frstart, frdur); - - // slightly different behavior, as we jump to start of current clip before - - if (not ri.empty()) { - - // scan.. - auto diff = item.rate() * ri.size(); - auto fuuid = Uuid(); - if (ri.back()) - fuuid = ri.back()->first.uuid(); - - for (auto it = ri.rbegin(); it != ri.rend(); ++it) { - if ((fuuid.is_null() and *it) or (not fuuid.is_null() and not*it) or - (not fuuid.is_null() and *it and fuuid != (*it)->first.uuid()) - - ) { - // handle at front of clip.. - if (frame == static_cast(diff / item.rate().to_flicks())) { - auto prev = std::next(it, 1); - if (prev != ri.rend()) { - fuuid = Uuid(); - if (*prev) - fuuid = (*prev)->first.uuid(); - } - } else { - result = diff / item.rate().to_flicks(); - break; - } - } - diff -= item.rate(); - } - } - } catch (...) { - } + } catch (std::exception & e) { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, e.what()); } - return result; } - QModelIndex SessionModel::getTimelineClipIndex(const QModelIndex &timelineIndex, const int frame) { auto result = QModelIndex(); @@ -2475,7 +2399,7 @@ void SessionModel::resetTimelineItemDragFlag(const QModelIndexList &items) { void SessionModel::updateTimelineItemDragFlag( const QModelIndexList &items, - const bool isRolling, + const QString &editMode, const bool isRipple, const bool isOverwrite) { @@ -2491,14 +2415,16 @@ void SessionModel::updateTimelineItemDragFlag( auto data = orig_data; - if (isRolling) { + if (editMode == "Roll") { data["show_rolling"] = true; + + } else if (editMode == "Move") { + data["show_drag_middle"] = true; } else { // if (not isOverwrite) { data["show_drag_left"] = true; data["show_drag_right"] = true; // } - data["show_drag_middle"] = true; if (not isRipple and not isOverwrite) { auto pre_index = index(i.row() - 1, 0, i.parent()); @@ -2686,7 +2612,6 @@ void SessionModel::beginTimelineItemDrag( } } - if (orig_data != data) setData(i, mapFromValue(data), userDataRole); } @@ -2861,6 +2786,43 @@ void SessionModel::updateTimelineItemDrag( } } +void SessionModel::pasteFromClipboard(const QString &clipboard_data, const QModelIndex &timeline_index) { + + auto timeline = actorFromQString(system(), timeline_index.data(actorRole).toString()); + scoped_actor sys(system()); + try { + if (!clipboard_data.startsWith("COPIED_CLIPS")) { + spdlog::error("pasteFromClipboard error: invalid clipboard data for Timeline paste."); + return; + } + const std::string clipboard_data_str = clipboard_data.mid(QString("COPIED_CLIPS").length()).toStdString(); + request_receive(*sys, timeline, timeline::insert_item_atom_v, clipboard_data_str); + } catch (std::exception &e) { + spdlog::error("pasteFromClipboard error: {}", e.what()); + } + +} + + +QString SessionModel::copyTimelineItemsToClipboard(const QModelIndexList &items, const QModelIndex &timeline_index) { + + auto selected_items = utility::UuidVector(); + + for (const auto &i : items) { + if (not i.isValid()) + continue; + + selected_items.push_back(UuidFromQUuid(i.data(idRole).toUuid())); + } + auto timeline = actorFromQString(system(), timeline_index.data(actorRole).toString()); + scoped_actor sys(system()); + + auto serialisation = request_receive( + *sys, timeline, utility::serialise_atom_v, selected_items); + + return QString("COPIED_CLIPS") + QString::fromStdString(serialisation.dump(2)); +} + void SessionModel::endTimelineItemDrag( const QModelIndexList &items, const QString &mode, const bool isOverwrite) { // items need to be persistent or they'll get messed up when we remove stuff. @@ -3352,6 +3314,7 @@ void SessionModel::endTimelineItemDrag( flushChange(); } } + } diff --git a/src/ui/qml/session/src/session_model_ui.cpp b/src/ui/qml/session/src/session_model_ui.cpp index 840c0da4d..b68d063cc 100644 --- a/src/ui/qml/session/src/session_model_ui.cpp +++ b/src/ui/qml/session/src/session_model_ui.cpp @@ -708,6 +708,7 @@ void SessionModel::receivedData( {Roles::audioActorUuidRole, "audio_actor_uuid"}, {Roles::bitDepthRole, "bit_depth"}, {Roles::bookmarkUuidsRole, "bookmark_uuids"}, + {Roles::clipEditedStatusRole, "clip_edited_status"}, {Roles::flagColourRole, "flag"}, {Roles::flagTextRole, "flag_text"}, {Roles::formatRole, "format"}, @@ -720,6 +721,7 @@ void SessionModel::receivedData( {Roles::notificationRole, "notification"}, {Roles::timecodeAsFramesRole, "timecode_as_frames"}, {Roles::pathShakeRole, "path_shake"}, + {Roles::audioModeRole, "audio_mode"}, {Roles::pixelAspectRole, "pixel_aspect"}, {Roles::rotationRole, "rotation"}, {Roles::rateFPSRole, "rate"}, @@ -1045,7 +1047,8 @@ nlohmann::json SessionModel::playlistTreeToJson( "actor": null, "busy": false, "media_count": 0, - "error_count": 0 + "error_count": 0, + "audio_mode": null })"_json); n["type"] = type; @@ -1214,6 +1217,7 @@ nlohmann::json SessionModel::containerDetailToJson( result["bookmark_uuids"] = nullptr; result["media_display_info"] = nullptr; result["rotation"] = nullptr; + result["notification"] = nullptr; } else if (detail.type_ == "MediaSource") { result["thumbnail_url"] = nullptr; result["rate"] = nullptr; @@ -1371,6 +1375,7 @@ nlohmann::json SessionModel::timelineItemToJson( case timeline::IT_TIMELINE: break; case timeline::IT_CLIP: + result["clip_edited_status"] = nullptr; break; } diff --git a/src/utility/src/CMakeLists.txt b/src/utility/src/CMakeLists.txt index ac2d96fa9..ac38ad2f3 100644 --- a/src/utility/src/CMakeLists.txt +++ b/src/utility/src/CMakeLists.txt @@ -20,7 +20,6 @@ SET(LINK_DEPS nlohmann_json::nlohmann_json spdlog::spdlog fmt::fmt - ZLIB::ZLIB ) SET(STATIC_LINK_DEPS @@ -29,7 +28,6 @@ SET(STATIC_LINK_DEPS nlohmann_json::nlohmann_json spdlog::spdlog fmt::fmt - ZLIB::ZLIB ) if(APPLE) @@ -48,9 +46,10 @@ endif() create_component_static(utility ${XSTUDIO_GLOBAL_VERSION} "${LINK_DEPS}" "${STATIC_LINK_DEPS}") -# OpenSSL is an implementation detail of md5_hash — keep it off the public link -# interface so downstream consumers (plugins) don't need to find_package(OpenSSL). +# OpenSSL (md5_hash) and ZLIB (json_store compression via zstr) are implementation +# details — keep them off the public link interface so downstream consumers +# (plugins) don't need to find_package(OpenSSL) / find_package(ZLIB). if(INSTALL_XSTUDIO) - target_link_libraries(utility PRIVATE OpenSSL::Crypto) + target_link_libraries(utility PRIVATE OpenSSL::Crypto ZLIB::ZLIB) endif() -target_link_libraries(utility_static PRIVATE OpenSSL::Crypto) \ No newline at end of file +target_link_libraries(utility_static PRIVATE OpenSSL::Crypto ZLIB::ZLIB) \ No newline at end of file diff --git a/src/utility/src/helpers.cpp b/src/utility/src/helpers.cpp index 526c5ba87..0db395e95 100644 --- a/src/utility/src/helpers.cpp +++ b/src/utility/src/helpers.cpp @@ -88,6 +88,10 @@ void xstudio::utility::add_remap_file_path(const std::string &from, const std::s s_remapper.add_path_mapping(from, to); } +void xstudio::utility::add_regex_mapping(const std::pair from, const std::pair to) { + s_remapper.add_regex_mapping(from, to); +} + std::string xstudio::utility::forward_remap_file_path(const std::string &path) { return s_remapper.forwards(path); } @@ -495,6 +499,8 @@ caf::uri xstudio::utility::parse_cli_posix_path( const std::regex xstudio_spec(R"(^(.*\{.+\}.*?)(=([-0-9x,]+))?$)", std::regex::optimize); const std::regex xstudio_shake( R"(^(.+\.)([#@]+)(\..+?)(=([-0-9x,]+))?$)", std::regex::optimize); + const std::regex xstudio_shake_no_dot_before_hash( + R"(^([^#@]+)([#@]+)(\..+?)(=([-0-9x,]+))?$)", std::regex::optimize); const std::regex xstudio_prefix_spec( R"(^(.*\.)([-0-9x,]+)(\{.+\}.*)$)", std::regex::optimize); const std::regex xstudio_prefix_shake( @@ -539,7 +545,7 @@ caf::uri xstudio::utility::parse_cli_posix_path( throw std::runtime_error("No frames specified."); } - } else if (std::regex_match(abspath.c_str(), m, xstudio_shake)) { + } else if (std::regex_match(abspath.c_str(), m, xstudio_shake) || std::regex_match(abspath.c_str(), m, xstudio_shake_no_dot_before_hash)) { size_t pad_c = 0; if (m[2].str() == "#") { pad_c = 4; @@ -548,7 +554,7 @@ caf::uri xstudio::utility::parse_cli_posix_path( } uri = posix_path_to_uri(m[1].str() + "{:0" + std::to_string(pad_c) + "d}" + m[3].str()); - // spdlog::error("posix_path_to_uri {}", to_string(uri)); + // spdlog::error("posix_path_to_uri {}", to_string(uri)); if (not m[5].str().empty()) { frame_list = FrameList(m[5].str()); diff --git a/src/utility/src/path_remapper.cpp b/src/utility/src/path_remapper.cpp index 9c2f501e9..10f178175 100644 --- a/src/utility/src/path_remapper.cpp +++ b/src/utility/src/path_remapper.cpp @@ -42,16 +42,23 @@ std::string PathRemapper::remap(const std::string &path, const bool forward) { // spdlog::warn("remap forward {} {} -> {}", forward, path, p); // } + // spdlog::warn("{} {} -> {}", forward ? "TO" : "FROM", path, p); + return p; } void PathRemapper::add_path_mapping(const std::string &from, const std::string &to) { - // spdlog::warn("add_path_mapping {} -> {}", from, to); std::scoped_lock lock(mutex_); forward_map_.emplace(std::make_pair(from, to)); backward_map_.emplace(std::make_pair(to, from)); } +void PathRemapper::add_regex_mapping(const std::pair from, const std::pair to) { + std::scoped_lock lock(mutex_); + forward_regex_.emplace_back(from); + backward_regex_.emplace_back(to); +} + void PathRemapper::configure(const utility::JsonStore &jsn) { std::scoped_lock lock(mutex_); diff --git a/ui/qml/xstudio/application/XsFloatingViewWindow.qml b/ui/qml/xstudio/application/XsFloatingViewWindow.qml index 65eb0eefc..18cd55911 100644 --- a/ui/qml/xstudio/application/XsFloatingViewWindow.qml +++ b/ui/qml/xstudio/application/XsFloatingViewWindow.qml @@ -164,7 +164,6 @@ XsWindow { break; } } - console.log("python_callback", v) return helpers.python_callback(python_func_name, module_uuid, v) } diff --git a/ui/qml/xstudio/application/XsSessionWindow.qml b/ui/qml/xstudio/application/XsSessionWindow.qml index 38320714a..1dba5056d 100644 --- a/ui/qml/xstudio/application/XsSessionWindow.qml +++ b/ui/qml/xstudio/application/XsSessionWindow.qml @@ -573,7 +573,7 @@ ApplicationWindow { viewsModel.register_view("qrc:/application/panels/playlists/XsPlaylists.qml", "Playlists", 1.0) viewsModel.register_view("qrc:/application/panels/media/XsMediaPanel.qml", "Media", 2.0) viewsModel.register_view("qrc:/application/panels/viewport/XsViewportPanel.qml", "Viewport", 3.0) - viewsModel.register_view("qrc:/application/panels/timeline/XsTimelinePanel.qml", "Timeline", 4.0) + viewsModel.register_view("qrc:/application/panels/timeline/XsTimelinePanelParent.qml", "Timeline", 4.0) viewsModel.register_view("divider", "divider", 5.0) // grading = 6.0 // annotation = 7.0 diff --git a/ui/qml/xstudio/application/auxillary/XsConformTool.qml b/ui/qml/xstudio/application/auxillary/XsConformTool.qml index ee5dc9477..5a06a25fe 100644 --- a/ui/qml/xstudio/application/auxillary/XsConformTool.qml +++ b/ui/qml/xstudio/application/auxillary/XsConformTool.qml @@ -435,7 +435,11 @@ Item{ menuPath: "More" menuItemPosition: 1 menuModelName: "media_list_menu_" - onActivated: (menuContext) => conformToSequence(menuContext.mediaSelection, viewportCurrentMediaContainerIndex, "Conformed Media") + onActivated: (menuContext) => { + if (theSessionData.lastTimelineIndex.valid) { + conformToSequence(menuContext.mediaSelection, theSessionData.lastTimelineIndex, "Conformed Media") + } + } } @@ -544,7 +548,9 @@ Item{ menuPath: "Auto-Conform" menuItemPosition: index menuModelName: "timeline_track_menu_" - onActivated: (menuContext) => autoConformSelectionTimeline(text, menuContext.theTimeline.conformSourceIndex, menuContext.theTimeline.timelineSelection.selectedIndexes[0]) + onActivated: (menuContext) => { + autoConformSelectionTimeline(text, menuContext.theTimeline.conformSourceIndex, menuContext.theTimeline.timelineSelection.selectedIndexes[0]) + } } XsMenuModelItem { diff --git a/ui/qml/xstudio/application/menus/XsSnippetMenu.qml b/ui/qml/xstudio/application/menus/XsSnippetMenu.qml index 4409b6c65..f689ca7f6 100644 --- a/ui/qml/xstudio/application/menus/XsSnippetMenu.qml +++ b/ui/qml/xstudio/application/menus/XsSnippetMenu.qml @@ -5,7 +5,6 @@ import xstudio.qml.models 1.0 import xstudio.qml.helpers 1.0 import xstudio.qml.viewport 1.0 - Item { XsMenuModelItem { text: "Reload" @@ -24,13 +23,25 @@ Item { Repeater { model: DelegateModel { model: embeddedPython.applicationMenuModel - delegate: Item {XsMenuModelItem { - text: nameRole - menuPath: menuPathRole - menuItemPosition: index+2 - menuModelName: "main menu bar" - onActivated: embeddedPython.pyEvalFile(scriptPathRole) - }} + delegate: Item { + XsHotkey { + id: hk + name: nameRole + description: nameRole + componentName: "Global Snippets" + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + sequence: menuHotKeyRole || null + } + + XsMenuModelItem { + hotkeyUuid: hk.uuid + text: nameRole + menuPath: menuPathRole + menuItemPosition: index+2 + menuModelName: "main menu bar" + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + } + } } } } \ No newline at end of file diff --git a/ui/qml/xstudio/application/menus/file_menu/XsFileFunctions.qml b/ui/qml/xstudio/application/menus/file_menu/XsFileFunctions.qml index 9a737b0a1..c882aa720 100644 --- a/ui/qml/xstudio/application/menus/file_menu/XsFileFunctions.qml +++ b/ui/qml/xstudio/application/menus/file_menu/XsFileFunctions.qml @@ -324,6 +324,12 @@ Item { defaultMediaFolder = folder } + function addSessionFromClipboard() { + if (clipboard.text) { + doImportSession(helpers.QUrlFromPosixPath(clipboard.text)) + } + } + function addMediaFromClipboard() { if (clipboard.data) { var index = sessionSelectionModel.currentIndex diff --git a/ui/qml/xstudio/application/menus/file_menu/XsFileMenu.qml b/ui/qml/xstudio/application/menus/file_menu/XsFileMenu.qml index b707b4074..d072f037c 100644 --- a/ui/qml/xstudio/application/menus/file_menu/XsFileMenu.qml +++ b/ui/qml/xstudio/application/menus/file_menu/XsFileMenu.qml @@ -207,6 +207,16 @@ Item { } } + XsMenuModelItem { + text: "Add Session From Clipboard" + menuPath: "File" + menuItemPosition: 6.5 + menuModelName: "main menu bar" + onActivated: { + file_functions.addSessionFromClipboard() + } + } + XsMenuModelItem { menuItemType: "divider" menuPath: "File" diff --git a/ui/qml/xstudio/application/menus/help_menu/XsAboutDialog.qml b/ui/qml/xstudio/application/menus/help_menu/XsAboutDialog.qml index 401e2dbec..98278b632 100644 --- a/ui/qml/xstudio/application/menus/help_menu/XsAboutDialog.qml +++ b/ui/qml/xstudio/application/menus/help_menu/XsAboutDialog.qml @@ -57,23 +57,20 @@ XsWindow { Architect
Chas Jarrett

-Lead Engineers
+Lead Developers
Al Crate and Ted Waine

-UI Developer
-Ron Kurian Maniangat

- Developers
-Remi Achard, Clement Jovet and Tomas Berzinskas

+Olaf Razzoli

-UI / UX
-Alex Tibbs

+Contributing Developers
+Remi Achard, Clement Jovet and Tomas Berzinskas

Project Management
-Carly Russell-Swain, Hannah Costello and Sam Melamed

+Carly Russell-Swain

Thanks To
-Jason Brown, Richard Jenns and Katherine Roberts
+Ron Kurian Maniangat, Alex Tibbs, Sam Melamed, Hannah Costello,
Jason Brown, Richard Jenns and Katherine Roberts

` textFormat: Text.RichText @@ -89,41 +86,6 @@ Jason Brown, Richard Jenns and Katherine Roberts
// gradient: styleGradient.accent_gradient color: XsStyleSheet.accentColor } - - Text { - text:'Overpass Font Copyright (c) 2016 by Red Hat, Inc. All rights reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
http://scripts.sil.org/OFL' - textFormat: Text.RichText - font.family: XsStyleSheet.fontFamily - font.pixelSize: 10 - font.hintingPreference: Font.PreferNoHinting - // anchors.top: btmDivLine.bottom - // anchors.left: parent.left - // color: XsStyleSheet.mainColor - onLinkActivated: Qt.openUrlExternally(link) - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton // we don't want to eat clicks on the Text - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - } - } - - Text { - text:'Open Source Icon Library Feather https://feathericons.com
is licensed under the MIT License.' - textFormat: Text.RichText - font.family: XsStyleSheet.fontFamily - font.pixelSize: 10 - font.hintingPreference: Font.PreferNoHinting - // anchors.top: overPassText.bottom - // anchors.bottom: parent.bottom - // anchors.left: parent.left - color: XsStyleSheet.primaryTextColor - onLinkActivated: Qt.openUrlExternally(link) - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton // we don't want to eat clicks on the Text - cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor - } - } } } diff --git a/ui/qml/xstudio/application/model/XsPlayhead.qml b/ui/qml/xstudio/application/model/XsPlayhead.qml index 405cd565c..e91431ffd 100644 --- a/ui/qml/xstudio/application/model/XsPlayhead.qml +++ b/ui/qml/xstudio/application/model/XsPlayhead.qml @@ -73,6 +73,12 @@ Item { model: playhead_attrs_model } + XsAttributeValue { + id: __playheadComparedTimelineIds + attributeTitle: "Compared Timeline Ids" + model: playhead_attrs_model + } + XsAttributeValue { id: __playheadBookmarkedFrames attributeTitle: "Bookmarked Frames" @@ -270,6 +276,7 @@ Item { property alias frameRate: __playheadFrameRate.value property alias playing: __playheadPlaying.value property alias cachedFrames: __playheadCachedFrames.value + property alias comparedTimelineIds: __playheadComparedTimelineIds.value property alias bookmarkedFrames: __playheadBookmarkedFrames.value property alias bookmarkedFrameColours: __playheadBookmarkedFrameColours.value property alias loopStartFrame: __playheadLoopStartFrame.value diff --git a/ui/qml/xstudio/application/panels/media/XsMediaToolBar.qml b/ui/qml/xstudio/application/panels/media/XsMediaToolBar.qml index e66052a87..fe34f512c 100644 --- a/ui/qml/xstudio/application/panels/media/XsMediaToolBar.qml +++ b/ui/qml/xstudio/application/panels/media/XsMediaToolBar.qml @@ -129,11 +129,10 @@ Item { stepSize: 1 valueText: parseFloat(cellSize/100).toFixed(2) - property real value: cellSize - onValueChanged:{ - - if(cellSize != value) - cellSize = value + value: cellSize + onValueEdited: (newValue) => { + if (cellSize != newValue) + cellSize = newValue } Component.onCompleted: { diff --git a/ui/qml/xstudio/application/panels/media/list_view/delegates/XsMediaItemDelegate.qml b/ui/qml/xstudio/application/panels/media/list_view/delegates/XsMediaItemDelegate.qml index dc02b2dc6..6c84a58c3 100644 --- a/ui/qml/xstudio/application/panels/media/list_view/delegates/XsMediaItemDelegate.qml +++ b/ui/qml/xstudio/application/panels/media/list_view/delegates/XsMediaItemDelegate.qml @@ -147,11 +147,26 @@ Rectangle { Component { id: selection_index XsMediaTextItem { - text: selectionIndex ? selectionIndex : "" + text: selectionIndex && !notify.visible ? selectionIndex : "" width: size height: itemRowHeight leftMargin: 2 isIndex: true + // notify widget + property bool forcedHover: hovered + + XsNotification { + id: notify + hovered: forcedHover + anchors.centerIn: parent + + width: Math.min(parent.width, parent.height) - 5 + height: Math.min(parent.width, parent.height) - 5 + visible: notificationRole !== undefined && notificationRole.length + text: notificationRole !== undefined && notificationRole.length ? notificationRole[0].text : "" + type: notificationRole !== undefined && notificationRole.length ? notificationRole[0].type : "" + percentage: notificationRole !== undefined && notificationRole.length ? notificationRole[0].progress_percent || 0.0 : 0.0 + } } } Component { diff --git a/ui/qml/xstudio/application/panels/media/list_view/dialogs/XsMediaListConfigureDialog.qml b/ui/qml/xstudio/application/panels/media/list_view/dialogs/XsMediaListConfigureDialog.qml index a3eb1bb15..078948a3d 100644 --- a/ui/qml/xstudio/application/panels/media/list_view/dialogs/XsMediaListConfigureDialog.qml +++ b/ui/qml/xstudio/application/panels/media/list_view/dialogs/XsMediaListConfigureDialog.qml @@ -169,9 +169,8 @@ metadata with media that isn't from your pipeline.` function flattenJSON(obj, path, result) { - for (var key in obj) { - if (typeof obj[key] == 'object') { + if (obj[key] != null && typeof obj[key] == 'object') { flattenJSON(obj[key], path + key + "/", result) } else { result.push([path + key, obj[key]]) diff --git a/ui/qml/xstudio/application/panels/media/widgets/XsMediaHotKeys.qml b/ui/qml/xstudio/application/panels/media/widgets/XsMediaHotKeys.qml index f8e6fdf7c..c334a3afb 100644 --- a/ui/qml/xstudio/application/panels/media/widgets/XsMediaHotKeys.qml +++ b/ui/qml/xstudio/application/panels/media/widgets/XsMediaHotKeys.qml @@ -240,7 +240,7 @@ XsHotkeyArea { XsHotkey { id: select_offline_hotkey name: "Select Offline Media" - description: "Sellect Offline Media in playlist/subset" + description: "Select Offline Media in playlist/subset" context: hotkey_area.context componentName: "Media List" onActivated: media_list_functions.selectAllOffline() diff --git a/ui/qml/xstudio/application/panels/media/widgets/XsMediaListContextMenu.qml b/ui/qml/xstudio/application/panels/media/widgets/XsMediaListContextMenu.qml index 7cbac5265..98b98e07a 100644 --- a/ui/qml/xstudio/application/panels/media/widgets/XsMediaListContextMenu.qml +++ b/ui/qml/xstudio/application/panels/media/widgets/XsMediaListContextMenu.qml @@ -36,7 +36,7 @@ XsPopupMenu { helpers.setMenuPathPosition("Copy To Clipboard|Selected Column", "media_list_menu_", 3) helpers.setMenuPathPosition("Reveal Source", "media_list_menu_", 60) helpers.setMenuPathPosition("Media Settings", "media_list_menu_", 1100) - helpers.setMenuPathPosition("Media Settings|Set Media Rotation", "media_list_menu_", 4) + helpers.setMenuPathPosition("Media Settings|Media Rotation", "media_list_menu_", 4) helpers.setMenuPathPosition("Media Actions", "media_list_menu_", 1200) helpers.setMenuPathPosition("Media Actions|Print", "media_list_menu_", 90) helpers.setMenuPathPosition("Snippet", "media_list_menu_", 1100) @@ -159,7 +159,7 @@ XsPopupMenu { XsFlagMenuInserter { menuPath: "Media Settings" - text: qsTr("Set Media Colour") + text: qsTr("Media Colour") panelContext: btnMenu.panelContext menuModelName: btnMenu.menu_model_name menuPosition: 1 @@ -392,9 +392,9 @@ XsPopupMenu { } XsMenuModelItem { - text: "Set Media FPS ..." + text: "Media FPS ..." menuPath: "Media Settings" - menuItemPosition: 2 + menuItemPosition: 2.5 menuModelName: btnMenu.menu_model_name onActivated: media_list_functions.setMediaFPS() panelContext: btnMenu.panelContext @@ -402,7 +402,7 @@ XsPopupMenu { } XsMenuModelItem { - text: "Set Media Pixel Aspect ..." + text: "Media Pixel Aspect ..." menuPath: "Media Settings" menuItemPosition: 3 menuModelName: btnMenu.menu_model_name @@ -423,7 +423,7 @@ XsPopupMenu { Item { XsMenuModelItem { text: "" + modelData - menuPath: "Media Settings|Set Media Rotation" + menuPath: "Media Settings|Media Rotation" menuItemPosition: index menuItemType: "toggle" menuModelName: btnMenu.menu_model_name @@ -436,7 +436,7 @@ XsPopupMenu { XsMenuModelItem { text: "Custom ..." - menuPath: "Media Settings|Set Media Rotation" + menuPath: "Media Settings|Media Rotation" menuItemPosition: 10.0 menuModelName: btnMenu.menu_model_name onActivated: { @@ -508,14 +508,27 @@ XsPopupMenu { Repeater { model: DelegateModel { model: embeddedPython.mediaMenuModel - delegate: Item {XsMenuModelItem { - text: nameRole - menuPath: "Snippet|"+menuPathRole - menuItemPosition: (index*0.01)+16 - menuModelName: btnMenu.menu_model_name - onActivated: embeddedPython.pyEvalFile(scriptPathRole) - panelContext: btnMenu.panelContext - }} + delegate: Item { + XsHotkey { + id: hk + name: nameRole + description: nameRole + componentName: "Media Snippets" + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + sequence: menuHotKeyRole || null + context: ""+btnMenu.panelContext + } + + XsMenuModelItem { + hotkeyUuid: hk.uuid + text: nameRole + menuPath: "Snippet|"+menuPathRole + menuItemPosition: (index*0.01)+16 + menuModelName: btnMenu.menu_model_name + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + panelContext: btnMenu.panelContext + } + } } } @@ -533,7 +546,9 @@ XsPopupMenu { menuItemPosition: 2010 menuModelName: btnMenu.menu_model_name hotkeyUuid: hotkey_area.delete_selected_hotkey.uuid - onActivated: media_list_functions.deleteSelected() + onActivated: { + media_list_functions.deleteSelected() + } panelContext: btnMenu.panelContext } } diff --git a/ui/qml/xstudio/application/panels/playlists/delegates/XsPlaylistItemBase.qml b/ui/qml/xstudio/application/panels/playlists/delegates/XsPlaylistItemBase.qml index 47cb0c97a..9a83dd77c 100644 --- a/ui/qml/xstudio/application/panels/playlists/delegates/XsPlaylistItemBase.qml +++ b/ui/qml/xstudio/application/panels/playlists/delegates/XsPlaylistItemBase.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Layouts import QtQml.Models 2.15 import QuickFuture 1.0 +import QtQuick.Effects import xstudio.qml.helpers 1.0 import xStudio 1.0 @@ -23,6 +24,7 @@ Item { property bool isDragReorderTarget: drag_drop_handler.isDragTarget && canReceiveDrag && incomingDragSource == "PlayList" property bool dragToEnd: false property bool isDivider: false + property int timlineCompareIndex: -1 property var incomingDragSource @@ -30,6 +32,21 @@ Item { property var placeHolder: placeHolderRole property var decoratorModel: [] + // if multiple timelines are compared, we track it here and update our + // decorations accordingly + property var comparedTimelineIds: currentPlayhead.comparedTimelineIds + onComparedTimelineIdsChanged: { + let timline_compared = -1 + for (var i = 0; i < comparedTimelineIds.length; ++i) { + if (helpers.QUuidFromUuidString(comparedTimelineIds[i]) == actorUuidRole) { + timline_compared = i + } + } + if (timlineCompareIndex != timline_compared) { + timlineCompareIndex = timline_compared + } + } + function updateDecorations() { if(typeRole == "Playlist" && !placeHolder) { let meta = theSessionData.getJSONObject(modelIndex, "/ui/decorators") @@ -333,14 +350,29 @@ Item { } } - XsIcon { + Item { + Layout.fillHeight: true Layout.preferredWidth: height Layout.margins: 2 - source: "qrc:/icons/desktop_windows.svg" - visible: isViewed - imgOverlayColor: XsStyleSheet.accentColor + XsIcon { + + anchors.fill: parent + source: "qrc:/icons/desktop_windows.svg" + visible: isViewed || timlineCompareIndex != -1 + imgOverlayColor: XsStyleSheet.accentColor + + XsText { + anchors.centerIn: parent + id: idx_label + visible: timlineCompareIndex != -1 + text: "" + (timlineCompareIndex + 1) + + } + + } + } Repeater { diff --git a/ui/qml/xstudio/application/panels/playlists/delegates/XsPlaylistItemDelegate.qml b/ui/qml/xstudio/application/panels/playlists/delegates/XsPlaylistItemDelegate.qml index bfa3cae54..a3ac94fa4 100644 --- a/ui/qml/xstudio/application/panels/playlists/delegates/XsPlaylistItemDelegate.qml +++ b/ui/qml/xstudio/application/panels/playlists/delegates/XsPlaylistItemDelegate.qml @@ -42,7 +42,6 @@ XsPlaylistItemBase { // playlists are one level in at row=0, column=0. rootIndex: subItemsModelIndex delegate: chooser - } DelegateChooser { diff --git a/ui/qml/xstudio/application/panels/playlists/menus/XsPlaylistContextMenu.qml b/ui/qml/xstudio/application/panels/playlists/menus/XsPlaylistContextMenu.qml index fcf651eb7..eedb8fe90 100644 --- a/ui/qml/xstudio/application/panels/playlists/menus/XsPlaylistContextMenu.qml +++ b/ui/qml/xstudio/application/panels/playlists/menus/XsPlaylistContextMenu.qml @@ -4,6 +4,7 @@ import QtQuick import xStudio 1.0 import xstudio.qml.models 1.0 import xstudio.qml.clipboard 1.0 +import xstudio.qml.viewport 1.0 import "." XsPopupMenu { @@ -184,14 +185,27 @@ XsPopupMenu { Repeater { model: DelegateModel { model: embeddedPython.playlistMenuModel - delegate: Item {XsMenuModelItem { - text: nameRole - menuPath: menuPathRole - menuItemPosition: (index*0.01)+8 - menuModelName: contextMenu.menu_model_name - onActivated: embeddedPython.pyEvalFile(scriptPathRole) - panelContext: contextMenu.panelContext - }} + delegate: Item { + XsHotkey { + id: hk + name: nameRole + description: nameRole + componentName: "Playlist Snippets" + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + sequence: menuHotKeyRole || null + context: ""+contextMenu.panelContext + } + + XsMenuModelItem { + hotkeyUuid: hk.uuid + text: nameRole + menuPath: menuPathRole + menuItemPosition: (index*0.01)+8 + menuModelName: contextMenu.menu_model_name + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + panelContext: contextMenu.panelContext + } + } } } diff --git a/ui/qml/xstudio/application/panels/timeline/XsTimeline.qml b/ui/qml/xstudio/application/panels/timeline/XsTimeline.qml index ee914a2ec..a2bfcb0f8 100644 --- a/ui/qml/xstudio/application/panels/timeline/XsTimeline.qml +++ b/ui/qml/xstudio/application/panels/timeline/XsTimeline.qml @@ -66,11 +66,9 @@ Rectangle { // property bool selectionIsEnabled: false property bool loopSelection: false property bool focusSelection: false - property alias timeline_items: list_view.model property alias timelineModel: list_view.model property alias timelineMarkerMenu: markerMenu - property alias timelineProperty: timelineProperty property string editMode: "Select" property bool rippleMode: false property bool overwriteMode: true @@ -80,6 +78,7 @@ Rectangle { property bool scrollingModeActive: false property var conformSourceIndex: null property bool have_timeline: true + property var timelinePlayheadSelectionIndex: null property int snapLine: -1 property int cutLine: -1 @@ -119,33 +118,15 @@ Rectangle { } } - XsModelProperty { - // XsModelProperty is able to 'watch' an index and emits onIndexChanged - // when the index goes invalid. We use this to watch whether the active - // timeline is deleted - if so we run viewedMediaSetChanged which will - // clear our model and index because otherwise Qt crashes as it seems - // to hang on to items that have been deleted from the model. - id: timelineProperty - index: timeline_items.rootIndex - role: "propertyRole" - onValueChanged: {updateConformSourceIndex();viewedMediaSetChanged()} - onIndexChanged: { - if(!index || !index.valid) { - // perhaps the timeline we were viewing has been deleted? - viewedMediaSetChanged() - } - } - } - function updateConformSourceIndex() { // resolve to track index. - let m = timeline_items.srcModel + let m = timelineModel.srcModel if(m == theSessionData) { - let current = m.get(timeline_items.rootIndex, "propertyRole") + let current = m.get(timelineIndex, "propertyRole") let tindex = getVideoTrackIndex(1) if(current != undefined) { - let stack = m.index(0, 0, timeline_items.rootIndex) + let stack = m.index(0, 0, timelineIndex) let ncsi = m.search(helpers.QVariantFromUuidString(current.conform_track_uuid), "idRole", stack, 0) if(ncsi.valid) { tindex = helpers.makePersistent(ncsi) @@ -158,9 +139,9 @@ Rectangle { onConformSourceIndexChanged: { if(conformSourceIndex && conformSourceIndex.valid) { - let m = timeline_items.srcModel + let m = timelineModel.srcModel if(m == theSessionData) { - let current = m.get(timeline_items.rootIndex, "propertyRole") + let current = m.get(timelineIndex, "propertyRole") let tid = m.get(conformSourceIndex, "idRole") if(current == undefined || current.conform_track_uuid != helpers.QUuidToQString(tid)) { @@ -170,7 +151,7 @@ Rectangle { current.conform_track_uuid = helpers.QUuidToQString(tid) } - m.set(timeline_items.rootIndex, current, "propertyRole") + m.set(timelineIndex, current, "propertyRole") } } } @@ -259,11 +240,11 @@ Rectangle { let selectedItems = timelineSelection.selectedIndexes sessionData.currentTimelineSelection = timelineSelection.selectedIndexes - theSessionData.setTimelineSelection(timeline_items.rootIndex, selectedItems) + theSessionData.setTimelineSelection(timelineIndex, selectedItems) // build list of media. let mediaIndexes = [] - let tindex = theSessionData.getTimelineIndex(timeline_items.rootIndex) + let tindex = theSessionData.getTimelineIndex(timelineIndex) let mediaListIndex = theSessionData.index(0, 0, tindex) for(let i =0; i< selectedItems.length; i++) { @@ -281,68 +262,42 @@ Rectangle { } } - function initTimeline(retry=true) { - let timelineIndex = theSessionData.index(2, 0, viewedMediaSetProperties.index) - - if (theSessionData.rowCount(timelineIndex) == 0) { - // the timeline item data hasn't been 'fetched' from the backend. - // We can force this to happen, but it will be asynchronous so - // we need a small delay to allow it to complete before we set - // the index on timeline_items (which triggers the build of - // the timeline UI components) - timeline_items.srcModel = null_list - theSessionData.fetchMore(timelineIndex) - callbackTimer.setTimeout(function() { return function() { - initTimeline() - }}(), 200); - - } else { - timeline_items.srcModel = theSessionData - timeline_items.rootIndex = helpers.makePersistent(timelineIndex) - have_timeline = true - updateConformSourceIndex() - callbackTimer.setTimeout(function() { return function() { - fitItems() - }}(), 200); - } - } - - function viewedMediaSetChanged() { - - if(viewedMediaSetProperties.index.valid && viewedMediaSetProperties.values.typeRole == "Timeline") { - forceActiveFocus() - - if (theSessionData.index(2, 0, viewedMediaSetProperties.index) == timeline_items.rootIndex) - return - - callbackTimer.setTimeout(function() { return function() { - initTimeline() - }}(), 50); - - } else if (!timeline_items.rootIndex.valid) { + property var tlIndexWatcher: timelineIndex + onTlIndexWatcherChanged: initTimeline(true) + + function initTimeline(retry) { + if (!timelineIndex.valid) { // if the user has selected something that is not a timeline (playlist, // subset etc.), we do not update our index here (unless the timeline // has been deleted or something). So the NLE continues to show the last // timeline that we interacted with. This is so the user can hop // bewtween individual media and the playlist without the playlist // interface clearing - timeline_items.srcModel = [] + timelineModel.srcModel = [] updateConformSourceIndex() have_timeline = false - } - } + } else if (theSessionData.rowCount(timelineIndex) == 0) { - Component.onCompleted: { - viewedMediaSetChanged() - //clipMenu.debugSetMenuPathPosition("Debug", 20.0) - //trackMenu.debugSetMenuPathPosition("Debug", 20.0) - } + timelineModel.srcModel = null_list + theSessionData.fetchMore(timelineIndex) + callbackTimer.setTimeout(function() { return function() { + timeline.initTimeline(false) + }}(), 200); + + } else { + timelineModel.srcModel = theSessionData + have_timeline = true + updateConformSourceIndex() + callbackTimer.setTimeout(function() { return function() { + fitItems() + }}(), 200); + let sind = theSessionData.searchRecursive("PlayheadSelection", "typeRole", timelineIndex.parent) + if (sind.valid) { + timelinePlayheadSelectionIndex = helpers.makePersistent(sind) + } + + } - Connections { - target: viewedMediaSetProperties - function onIndexChanged() { - viewedMediaSetChanged() - } } Connections { @@ -350,7 +305,7 @@ Rectangle { function onMakeTimelineSelection(timeline_index, timeline_item_indeces) { // this connection lets items remote from the Timeline UI (like the // Notes panel) control the selection of items in the Timeline UI. - if (timeline_index == timeline_items.rootIndex.parent) { + if (timeline_index == timelineIndex.parent) { focusSelection = true timelineSelection.select( helpers.createItemSelection(timeline_item_indeces), @@ -365,8 +320,8 @@ Rectangle { } function addTrack(type, sync=true, name=null) { - let m = timeline_items.srcModel - let stack_index = m.index(0, 0, timeline_items.rootIndex) + let m = timelineModel.srcModel + let stack_index = m.index(0, 0, timelineIndex) if(type == "Video Track") return addItem(type, stack_index, 0, name || "Video Track", sync) else if(type == "Audio Track") @@ -439,6 +394,7 @@ Rectangle { // will be set by the derived type XsTimelineMenu { id: timelineMenu + theTimeline: timeline Component.onCompleted: { helpers.setMenuPathPosition("Debug", "timeline_menu_", 100) } @@ -450,10 +406,12 @@ Rectangle { XsTimelineClipMenu { id: clipMenu + theTimeline: timeline } XsTimelineTrackMenu { id: trackMenu + theTimeline: timeline } XsPopupMenu { @@ -556,21 +514,92 @@ Rectangle { } - function fitToMedia(indexes) { + function setClipToFullMediaLength(indexes) { for(let i=0; i Object.values(obj)[0]) colours[0] = "" @@ -1306,7 +1357,7 @@ Rectangle { onActivated: { if(!timeline.timelineSelection.selectedIndexes.length){ // transportBar.skipToNext(true) - timelinePlayhead.logicalFrame = theSessionData.getNextTimelineClipFrame(timeline_items.rootIndex, timelinePlayhead.logicalFrame) + theSessionData.jumpToNextClip(timelineIndex, true) } else updateItemSelectionHorizontal(-1,1) @@ -1320,7 +1371,7 @@ Rectangle { exclusive: theTimeline.have_timeline && isPlayheadActive onActivated: { if(!timeline.timelineSelection.selectedIndexes.length) { - timelinePlayhead.logicalFrame = theSessionData.getPreviousTimelineClipFrame(timeline_items.rootIndex, timelinePlayhead.logicalFrame) + theSessionData.jumpToNextClip(timelineIndex, false) } else updateItemSelectionHorizontal(1,-1) @@ -1676,7 +1727,7 @@ Rectangle { } else if(button == Qt.LeftButton) { if(pressed && scalingModeActive) { isScaling = scalingModeActive - minScaleX = ((list_view.width - trackHeaderWidth) / theSessionData.timelineRect([timeline_items.rootIndex]).width)/2 + minScaleX = ((list_view.width - trackHeaderWidth) / theSessionData.timelineRect([timelineIndex]).width)/2 initialValue = scaleX initialPosition = Qt.point(mouseX, mouseY) } else if(pressed && scrollingModeActive) { @@ -1781,7 +1832,7 @@ Rectangle { if(!updateRegionTimer.running) updateRegionTimer.start() } else { - if(editMode == "Move" || editMode == "Roll") { + if(editMode == "Move" || editMode == "Trim" || editMode == "Roll") { showHandles(mouse.x, mouse.y) } else if(editMode == "Cut") { // calculate timeline frame posisiton. @@ -1791,7 +1842,7 @@ Rectangle { if(snapMode) { let matched = theSessionData.snapTo( - timeline_items.rootIndex, + timelineIndex, list_view.playheadFrame, newCut, 0, 0, 10 / scaleX, snapCacheKey) if(matched.length) { @@ -1841,7 +1892,7 @@ Rectangle { } else { tmp -= 0.2 } - scaleX = Math.max((list_view.width - trackHeaderWidth) / theSessionData.timelineRect([timeline_items.rootIndex]).width, tmp) + scaleX = Math.max((list_view.width - trackHeaderWidth) / theSessionData.timelineRect([timelineIndex]).width, tmp) list_view.itemAtIndex(0).jumpToFrame(timelinePlayhead.logicalFrame, ListView.Center) wheel.accepted = true } else if(Math.abs(deltaX) > Math.abs(deltaY)) { @@ -1866,16 +1917,37 @@ Rectangle { return if("Clip" == item_type) - theSessionData.updateTimelineItemDragFlag([hovered.modelIndex()], editMode == "Roll", rippleMode, overwriteMode) + theSessionData.updateTimelineItemDragFlag([hovered.modelIndex()], editMode, rippleMode, overwriteMode) } } } function draggingStarted(index, item, mode) { + if(!timelineSelection.selectedIndexes.includes(index)) { timelineSelection.select(index, ItemSelectionModel.ClearAndSelect) } + if (rippleMode) { + // for ripple mode, we only need to keep the leftmost clip for any + // given track in the selection because the ripple will be applied to all clips on that track. + let selectedIndexes = timelineSelection.selectedIndexes + let leftmostIndexes = [] + let trackMap = {} + for (let i = 0; i < selectedIndexes.length; i++) { + let idx = selectedIndexes[i] + if (idx.model.get(idx, "typeRole") != "Clip") continue; + let trackNum = idx.parent.row + if (!(trackNum in trackMap) || idx.model.get(idx, "trimmedStartRole") < trackMap[trackNum].model.get(idx, "trimmedStartRole")) { + trackMap[trackNum] = idx + } + } + for (let key in trackMap) { + leftmostIndexes.push(trackMap[key]) + } + timelineSelection.select(helpers.createItemSelection(leftmostIndexes), ItemSelectionModel.ClearAndSelect) + } + snapCacheKey = helpers.makeQUuid() if(mode == "left") theSessionData.beginTimelineItemDrag(timelineSelection.selectedIndexes, mode, rippleMode, overwriteMode) @@ -2107,12 +2179,12 @@ Rectangle { ListView { anchors.fill: parent interactive: false - id:list_view + id: list_view model: DelegateModel { - // id: timeline_items + // id: timelineModel property var srcModel: theSessionData model: srcModel - rootIndex: helpers.qModelIndex() + rootIndex: timelineIndex delegate: DelegateChooser { role: "typeRole" @@ -2241,7 +2313,7 @@ Rectangle { let new_indexes = theSessionData.moveRows( data, -1, // insertion row: make invalid so always inserts on the end - timeline_items.rootIndex.parent, + timelineIndex.parent, true ) if(dragReplace.visible) { diff --git a/ui/qml/xstudio/application/panels/timeline/XsTimelinePanel.qml b/ui/qml/xstudio/application/panels/timeline/XsTimelinePanel.qml index c069e501a..43655e416 100644 --- a/ui/qml/xstudio/application/panels/timeline/XsTimelinePanel.qml +++ b/ui/qml/xstudio/application/panels/timeline/XsTimelinePanel.qml @@ -12,7 +12,7 @@ import xStudio 1.0 XsGradientRectangle { id: panel - anchors.fill: parent + //anchors.fill: parent property color bgColorPressed: XsStyleSheet.accentColor property color bgColorNormal: "transparent" property color forcedBgColorNormal: bgColorNormal @@ -29,27 +29,45 @@ XsGradientRectangle { property real btnHeight: XsStyleSheet.widgetStdHeight+4 property real panelPadding: XsStyleSheet.panelPadding - property var currentClipRange: [] - property var currentClipHandles: [] + property int inHandle: 0 + property int outHandle: 0 + property int cutRangeIn: 0 + property int cutRangeOut: 0 + property int editBoxWidth: 34 + property int editBoxHeight: 15 + + property bool haveCurrentClip: false property var currentClipIndex: null - property var timelinePlayheadSelectionIndex: null //#TODO: test property bool showIcons: false property alias theTimeline: theTimeline + property alias timelineProperties: timelineProperties property bool hideMarkers: false property string timeMode: "timecode" property real verticalScale: 1.0 - property bool isPlayheadActive: timelinePlayhead.pinnedSourceMode ? currentPlayhead.uuid == timelinePlayhead.uuid : false - // persist these properties between sessions XsStoredPanelProperties { propertyNames: ["hideMarkers", "verticalScale", "timeMode"] } + /* This gives us direct access to the properties of the current (active) + playlist - for example viewedMediaSetProperties.values.nameRole gives us + the playlist name */ + XsModelPropertyMap { + id: timelineProperties + index: timelineIndex.parent + } + + XsModelPropertyMap { + id: parentPlaylistProperties + index: timelineIndex.parent.parent.parent + } + property var timelineName: timelineProperties.values.nameRole ? parentPlaylistProperties.values.nameRole + " / " + timelineProperties.values.nameRole : "" + XsModelPropertyMap { id: currentClipProperties index: currentClipIndex || theSessionData.index(-1,-1) @@ -77,7 +95,7 @@ XsGradientRectangle { let mediaIndex = model.search(currentClipProperties.values.clipMediaUuidRole, "actorUuidRole", mlist) if(model.canFetchMore(mediaIndex)) { model.fetchMore(mediaIndex) - delay(250, function() {updateCurrentClipDetail()}) + delay(250, function() {panel.updateCurrentClipDetail()}) } else { let mediaSourceIndex = model.search( model.get(mediaIndex, "imageActorUuidRole"), @@ -85,7 +103,7 @@ XsGradientRectangle { ) let taf = model.get(mediaSourceIndex, "timecodeAsFramesRole") if(taf == undefined) { - delay(250, function() {updateCurrentClipDetail()} ) + delay(250, function() {panel.updateCurrentClipDetail()} ) } else { // let name = currentClipProperties.values.nameRole let start = currentClipProperties.values.trimmedStartRole @@ -97,17 +115,72 @@ XsGradientRectangle { start = start - astart + taf let end = start + duration - 1 - currentClipRange = [start, end] - currentClipHandles = [head, tail] + cutRangeIn = start + cutRangeOut = end + inHandle = head + outHandle = tail + haveCurrentClip = true } } } else { - currentClipRange = [] - currentClipHandles = [] + haveCurrentClip = false } } - XsPlayhead { + function setCutRange(text, what) { + let v = parseInt(text) + + if(currentClipProperties.index && currentClipProperties.index.valid) { + let model = currentClipProperties.index.model + let tindex = model.getPlaylistIndex(currentClipProperties.index) + let mlist = model.index(0, 0, tindex) + let mediaIndex = model.search(currentClipProperties.values.clipMediaUuidRole, "actorUuidRole", mlist) + let mediaSourceIndex = model.search( + model.get(mediaIndex, "imageActorUuidRole"), + "actorUuidRole", mediaIndex + ) + let taf = model.get(mediaSourceIndex, "timecodeAsFramesRole") + if(taf == undefined) { + return + } else { + // let name = currentClipProperties.values.nameRole + let start = currentClipProperties.values.trimmedStartRole + let astart = currentClipProperties.values.availableStartRole + let duration = currentClipProperties.values.trimmedDurationRole + let head = start - astart + let tail = currentClipProperties.values.availableDurationRole - head - duration + + start = start - astart + taf + let end = start + duration - 1 + + cutRangeIn = start + cutRangeOut = end + inHandle = head + outHandle = tail + haveCurrentClip = true + + if (what == 0) { // cutIn + let d = v-cutRangeIn + currentClipProperties.values.activeStartRole = currentClipProperties.values.activeStartRole + d + currentClipProperties.values.activeDurationRole = currentClipProperties.values.activeDurationRole - d + } else if (what == 1) { // cutOut + let d = cutRangeOut-v + currentClipProperties.values.activeDurationRole = currentClipProperties.values.activeDurationRole - d + } else if (what == 2) { // inHandle + let d = v-inHandle + currentClipProperties.values.activeStartRole = currentClipProperties.values.activeStartRole + d + currentClipProperties.values.activeDurationRole = currentClipProperties.values.activeDurationRole - d + } else if (what == 3) { // outHandle + let d = v-outHandle + currentClipProperties.values.activeDurationRole = currentClipProperties.values.activeDurationRole - d + } + + updateCurrentClipDetail() + } + } + } + + /*XsPlayhead { id: timelinePlayhead Component.onCompleted: { connectToModel(0) @@ -150,8 +223,6 @@ XsGradientRectangle { } } - property alias timelinePlayhead: timelinePlayhead - Connections { target: theTimeline.timelineModel @@ -164,7 +235,7 @@ XsGradientRectangle { timelinePlayhead.connectToModel(0) } } - } + }*/ Connections { target: timelinePlayhead @@ -195,7 +266,10 @@ XsGradientRectangle { hoverEnabled: true onClicked: { if(!isPlayheadActive) { - viewportCurrentMediaContainerIndex = theTimeline.timelineModel.rootIndex.parent + + if (!multiTimelineMode) { + viewportCurrentMediaContainerIndex = theTimeline.timelineModel.rootIndex.parent + } // we ensure the timeline playhead is back in 'pinned' mode. This // means, regardless of the media selection, the playhead source @@ -235,6 +309,37 @@ XsGradientRectangle { spacing: 2 anchors.fill: parent + XsPrimaryButton{ + Layout.preferredWidth: btnWidth + Layout.preferredHeight: parent.height + imgSrc: "qrc:/icons/content_copy.svg" + text: "Copy selected tracks/clips to clipboard" + copy_key.seqDisplay + onClicked: { + clipboard.text = theSessionData.copyTimelineItemsToClipboard(theTimeline.timelineSelection.selectedIndexes, timelineIndex) + } + enabled: theTimeline.timelineSelection.selectedIndexes.length + XsHotkeyReference { + id: copy_key + hotkeyName: "Timeline Copy Selected Items to Clipboard" + property var seqDisplay: sequence != "" ? " (" + sequence + ")": "" + } + } + + XsPrimaryButton{ + Layout.preferredWidth: btnWidth + Layout.preferredHeight: parent.height + imgSrc: "qrc:/icons/content_paste.svg" + text: "Paste tracks/clips from clipboard" + paste_key.seqDisplay + onClicked: theSessionData.pasteFromClipboard(clipboard.text, timelineIndex) + enabled: clipboard.text.startsWith("COPIED_CLIPS") + XsHotkeyReference { + id: paste_key + hotkeyName: "Timeline Paste Items from Clipboard" + property var seqDisplay: sequence != "" ? " (" + sequence + ")": "" + } + + } + XsPrimaryButton{ id: deleteBtn Layout.preferredWidth: btnWidth Layout.preferredHeight: parent.height @@ -426,99 +531,81 @@ XsGradientRectangle { Layout.fillWidth: true } - RowLayout { - Layout.preferredWidth: btnWidth*5 - Layout.maximumWidth: btnWidth*5 - Layout.fillHeight: true - - ColumnLayout { - Layout.preferredWidth: btnWidth*1.8 - Layout.maximumWidth: btnWidth*1.8 - Layout.fillHeight: true - - XsText{ - Layout.fillWidth: true - Layout.fillHeight: true - elide: Text.ElideMiddle - text: "Cut Range:" - horizontalAlignment: Text.AlignRight - } - - XsText{ - Layout.fillWidth: true - Layout.fillHeight: true - elide: Text.ElideMiddle - text: "Handles:" - horizontalAlignment: Text.AlignRight - } + Layout.alignment: Qt.AlignVCenter + spacing: 0 + enabled: haveCurrentClip + + XsText{ + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + Layout.rightMargin: 4 + text: "Cut Range" + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight } - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true - - XsText{ - Layout.fillWidth: true - Layout.fillHeight: true - font.bold: true - font.family: XsStyleSheet.fixedWidthFontFamily - elide: Text.ElideMiddle - text: currentClipRange.length ? currentClipRange[0] : "" - horizontalAlignment: Text.AlignRight - } - - XsText{ - Layout.fillWidth: true - Layout.fillHeight: true - font.bold: true - font.family: XsStyleSheet.fixedWidthFontFamily - elide: Text.ElideMiddle - text: currentClipHandles.length ? (Math.abs(currentClipHandles[0]) + (currentClipHandles[0] < 0 ? " -" : "")) : "" - horizontalAlignment: Text.AlignRight - } + XsTextField { + Layout.preferredWidth: editBoxWidth + Layout.preferredHeight: editBoxHeight + text: cutRangeIn + font.pixelSize: 10 + horizontalAlignment: TextInput.AlignHCenter + onEditingFinished: setCutRange(text, 0) + } + XsText { + text: " - " + font.pixelSize: 10 + } + XsTextField { + Layout.preferredWidth: editBoxWidth + Layout.preferredHeight: editBoxHeight + text: cutRangeOut + font.pixelSize: 10 + horizontalAlignment: TextInput.AlignHCenter + onEditingFinished: setCutRange(text, 1) - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true - - XsText{ - Layout.fillHeight: true - text: " - " - } - - XsText{ - Layout.fillHeight: true - text: " / " - } } - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true + Item { + Layout.preferredWidth: 20 + } - XsText{ - Layout.fillWidth: true - Layout.fillHeight: true - font.bold: true - elide: Text.ElideMiddle - text: currentClipRange.length ? currentClipRange[1] : "" - font.family: XsStyleSheet.fixedWidthFontFamily - horizontalAlignment: Text.AlignLeft - } + XsText { + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + Layout.rightMargin: 4 + text: "Handles" + horizontalAlignment: Text.AlignRight + font.pixelSize: 10 + } - XsText{ - Layout.fillWidth: true - Layout.fillHeight: true - font.bold: true - elide: Text.ElideMiddle - font.family: XsStyleSheet.fixedWidthFontFamily - text: currentClipHandles.length ? (Math.abs(currentClipHandles[1]) + (currentClipHandles[1] < 0 ? " -" : "")): "" - horizontalAlignment: Text.AlignLeft - } + XsTextField { + Layout.preferredWidth: editBoxWidth + Layout.preferredHeight: editBoxHeight + text: inHandle + font.pixelSize: 10 + horizontalAlignment: TextInput.AlignHCenter + onEditingFinished: setCutRange(text, 2) + } + XsText { + text: " / " + font.pixelSize: 10 + } + XsTextField { + Layout.preferredWidth: editBoxWidth + Layout.preferredHeight: editBoxHeight + text: outHandle + font.pixelSize: 10 + horizontalAlignment: TextInput.AlignHCenter + onEditingFinished: setCutRange(text, 3) + } + + Item { + Layout.preferredWidth: 8 } + } + } } @@ -565,6 +652,17 @@ XsGradientRectangle { onClicked: theTimeline.editMode = text } + XsPrimaryButton{ + Layout.minimumHeight: btnHeight + Layout.maximumHeight: btnHeight + Layout.fillWidth: true + text: "Trim" + isActiveIndicatorAtLeft: true + imgSrc: "qrc:/icons/horizontal_align_center.svg" + isActive: theTimeline.editMode == text + onClicked: theTimeline.editMode = text + } + XsPrimaryButton{ Layout.minimumHeight: btnHeight Layout.maximumHeight: btnHeight diff --git a/ui/qml/xstudio/application/panels/timeline/XsTimelinePanelParent.qml b/ui/qml/xstudio/application/panels/timeline/XsTimelinePanelParent.qml new file mode 100644 index 000000000..a0f031267 --- /dev/null +++ b/ui/qml/xstudio/application/panels/timeline/XsTimelinePanelParent.qml @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +import xstudio.qml.helpers 1.0 +import xstudio.qml.models 1.0 +import xstudio.qml.viewport 1.0 +import xstudio.qml.clipboard 1.0 + +import xStudio 1.0 + +Item { + + id: panel + anchors.fill: parent + + property var viewedTimelineIndex: helpers.qModelIndex() + property var timelines: [helpers.qModelIndex()] + property bool multiTimelineMode: timelines.length > 1 + + XsPreference { + id: __show_clip_handles + path: "/core/sequence/show_clip_handles" + } + property alias show_clip_handles: __show_clip_handles.value + + XsPreference { + id: __show_edit_indicator + path: "/core/sequence/show_edit_indicator" + } + property alias show_edit_indicator: __show_edit_indicator.value + + + Clipboard { + id: clipboard + } + + property alias clipboard: clipboard + + function setTimelines() { + // tricky logic implemented here. We only enter multi-timeline compare if + // the current viewed container is a timeline, and also if the same container + // is selected in the playlist panel, and also if more than one timeline + // is selected. Otherwise we fall back to 'normal' mode where a single + // timeline is displayed (the current or last viewed timeline) + let stw = theSessionData.selectedTimelinesIndeces + let newtimelines = [viewedTimelineIndex] + let viewedTimlineIsSelected = stw.indexOf(viewedMediaSetProperties.index) != -1 + + if (viewedTimlineIsSelected && stw.length > 1) { + for (var i = 0; i < stw.length; ++i) { + if (stw[i] == viewedMediaSetProperties.index) continue + let timelineIndex = helpers.makePersistent(theSessionData.index(2, 0, stw[i])) + if (theSessionData.get(stw[i], "typeRole") == "Timeline") { + newtimelines.push(stw[i]) + } + } + } + + var changed = newtimelines.length != timelines.length + if (!changed) { + // js can't compare these arrays. weird. + for (var i = 0; i < newtimelines.length; ++i) { + if (newtimelines[i] != timelines[i]) { + changed = true + break + } + } + } + if (changed) timelines = newtimelines + + } + + ColumnLayout { + anchors.fill: parent + spacing: 1 + Repeater { + model: timelines + XsTimelinePanel { + Layout.fillWidth: true + Layout.fillHeight: true + property var timelineIndex: helpers.makePersistent(theSessionData.index(2, 0,timelines[index])) + objectName: "XsTimelinePanel" + } + } + } + + /*property var timelineIndex: helpers.qModelIndex()*/ + + function viewedMediaSetChanged() { + + if(viewedMediaSetProperties.index.valid && viewedMediaSetProperties.values.typeRole == "Timeline") { + + viewedTimelineIndex = helpers.makePersistent(viewedMediaSetProperties.index) + forceActiveFocus() + getViewedTimelinePlayheadUuid(0) + + } + setTimelines() + + } + + Component.onCompleted: { + viewedMediaSetChanged() + } + + Connections { + target: viewedMediaSetProperties + function onIndexChanged() { + viewedMediaSetChanged() + } + } + + Connections { + target: theSessionData + function onSelectedTimelinesIndecesChanged() { + setTimelines() + } + } + + /*XsModelProperty { + // XsModelProperty is able to 'watch' an index and emits onIndexChanged + // when the index goes invalid. We use this to watch whether the active + // timeline is deleted - if so we run viewedMediaSetChanged which will + // clear our model and index because otherwise Qt crashes as it seems + // to hang on to items that have been deleted from the model. + id: timelineProperty + index: timelineIndex ? timelineIndex : helpers.qModelIndex() + role: "propertyRole" + onValueChanged: { viewedMediaSetChanged() } + onIndexChanged: { + if(!index || !index.valid) { + // perhaps the timeline we were viewing has been deleted? + // This will make sure the timeline UI will get cleared + timelineIndex = helpers.qModelIndex() + } + } + }*/ + + // We need to enact some tricky logic here. If a timeline is selected for + // playing in the viewport, then timelinePlayhead should be connected to + // the playhead of that timeline. + // If something other than a timeline is selected for playing, but we + // already have loaded a timeline into the TimelineUI panel, then + // timelinePlayhead should remain connected to the playhead of the timeline. + // If we are in multi-timeline compare mode (a timeline is selected for + // viewing and there are also other timelines selected in the Playlists panel) + // then timelinePlayhead should connect to the special multiTimeline playhead + // that is owned by the xSTUDIO Session object. + property var multiComparePlayheadID: helpers.QUuidFromUuidString("442f6169-a3b0-465d-850d-03280b224d70") + property var currentPlayheadUuid: currentPlayhead.uuid + property alias timelinePlayhead: timelinePlayhead + property bool isPlayheadActive: timelinePlayhead.pinnedSourceMode ? currentPlayhead.uuid == timelinePlayhead.uuid : false + property var viewedTimelinePlayheadUuid: undefined + + XsPlayhead { + id: timelinePlayhead + uuid: timelines.length > 1 ? multiComparePlayheadID : viewedTimelinePlayheadUuid + Component.onCompleted: { + getViewedTimelinePlayheadUuid(0) + } + } + + function getViewedTimelinePlayheadUuid(retry) { + + // connect to the timeline playhead ... + let playhead_idx = theSessionData.searchRecursive( + "Playhead", + "typeRole", + viewedTimelineIndex + ) + + if (playhead_idx.valid) { + let playhead_uuid = theSessionData.get(playhead_idx, "actorUuidRole") + if (playhead_uuid == undefined) { + // uh-oh - remember the session model is populated asynchronously + // we might need to wait few milliseconds until "actorUuidRole" for + // the playhead has been filled in. + if (retry < 3) { + callbackTimer.setTimeout(function() { return function() { + getViewedTimelinePlayheadUuid(retry+1) + }}(), 200); + } + } else { + viewedTimelinePlayheadUuid = playhead_uuid + } + } else if (retry < 3) { + callbackTimer.setTimeout(function() { return function() { + getViewedTimelinePlayheadUuid(retry+1) + }}(), 200); + } + + } + +} \ No newline at end of file diff --git a/ui/qml/xstudio/application/panels/timeline/delegates/XsDelegateClip.qml b/ui/qml/xstudio/application/panels/timeline/delegates/XsDelegateClip.qml index 2177b01d6..95644d1fc 100644 --- a/ui/qml/xstudio/application/panels/timeline/delegates/XsDelegateClip.qml +++ b/ui/qml/xstudio/application/panels/timeline/delegates/XsDelegateClip.qml @@ -90,6 +90,10 @@ RowLayout { return helpers.makePersistent(DelegateModel.model.modelIndex(index)) } + function nmodelIndex() { + return DelegateModel.model.modelIndex(index) + } + Timer { id: updateTimer interval: 500 @@ -189,7 +193,7 @@ RowLayout { } onDraggingStarted: (mode)=> { - control.draggingStarted(modelIndex(), control, mode) + control.draggingStarted(nmodelIndex(), control, mode) isDragging = true } onDragging: control.dragging(modelIndex(), control, mode, x / scaleX, y / scaleY / config.itemHeight) diff --git a/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineClipMenu.qml b/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineClipMenu.qml index 14f9b4599..fb1313445 100644 --- a/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineClipMenu.qml +++ b/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineClipMenu.qml @@ -3,6 +3,7 @@ import QtQuick import Qt.labs.qmlmodels +import xstudio.qml.viewport 1.0 import xstudio.qml.models 1.0 import xStudio 1.0 @@ -38,6 +39,8 @@ XsPopupMenu { let mp = si.model.get(si, "menuPathRole") helpers.setMenuPathPosition(mp,"timeline_clip_menu_", 81 + ((1.0/rc)*i) ) } + + helpers.setMenuPathPosition("Set Trim", "timeline_clip_menu_", 8) } @@ -356,11 +359,29 @@ XsPopupMenu { } XsMenuModelItem { - text: qsTr("Fit To Media") - menuPath: "" - menuItemPosition: 5.5 + text: qsTr("Trim To Media Length") + menuPath: "Set Trim" + menuItemPosition: 1.0 + menuModelName: timelineMenu.menu_model_name + onActivated: theTimeline.setClipToFullMediaLength(timelineSelection.selectedIndexes) + panelContext: timelineMenu.panelContext + } + + XsMenuModelItem { + text: qsTr("Trim to Original Cut Range") + menuPath: "Set Trim" + menuItemPosition: 2.0 + menuModelName: timelineMenu.menu_model_name + onActivated: theTimeline.restoreClipsToOriginalEdit(timelineSelection.selectedIndexes) + panelContext: timelineMenu.panelContext + } + + XsMenuModelItem { + text: qsTr("Trim to Custom Cut Range") + menuPath: "Set Trim" + menuItemPosition: 3.0 menuModelName: timelineMenu.menu_model_name - onActivated: theTimeline.fitToMedia(timelineSelection.selectedIndexes) + onActivated: theTimeline.restoreClipsToPreviousEdit(timelineSelection.selectedIndexes) panelContext: timelineMenu.panelContext } @@ -460,14 +481,27 @@ XsPopupMenu { Repeater { model: DelegateModel { model: embeddedPython.clipMenuModel - delegate: Item {XsMenuModelItem { - text: nameRole - menuPath: menuPathRole - menuItemPosition: (index*0.01)+80 - menuModelName: timelineMenu.menu_model_name - onActivated: embeddedPython.pyEvalFile(scriptPathRole) - panelContext: timelineMenu.panelContext - }} + delegate: Item { + XsHotkey { + id: hk + name: nameRole + description: nameRole + componentName: "Clip Snippets" + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + sequence: menuHotKeyRole || null + context: ""+timelineMenu.panelContext + } + + XsMenuModelItem { + hotkeyUuid: hk.uuid + text: nameRole + menuPath: menuPathRole + menuItemPosition: (index*0.01)+80 + menuModelName: timelineMenu.menu_model_name + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + panelContext: timelineMenu.panelContext + } + } } } diff --git a/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineMenu.qml b/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineMenu.qml index 90a9527e2..e7569d041 100644 --- a/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineMenu.qml +++ b/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineMenu.qml @@ -3,6 +3,7 @@ import QtQuick import xStudio 1.0 import xstudio.qml.helpers 1.0 import xstudio.qml.models 1.0 +import xstudio.qml.viewport 1.0 XsPopupMenu { @@ -44,6 +45,7 @@ XsPopupMenu { Component.onCompleted: { helpers.setMenuPathPosition("Time Mode", "timeline_menu_", 1.9) + helpers.setMenuPathPosition("Audio Mode", "timeline_menu_", 1.95) // need to reorder snippet menus.. let rc = embeddedPython.sequenceMenuModel.rowCount(); for(let i=0; i < embeddedPython.sequenceMenuModel.rowCount(); i++) { @@ -115,6 +117,30 @@ XsPopupMenu { onActivated: timeMode = "timecode" } + XsMenuModelItem { + text: qsTr("Play Audio from Audio Tracks") + menuPath: "Audio Mode" + menuItemPosition: 1.0 + menuModelName: timelineMenu.menu_model_name + panelContext: timelineMenu.panelContext + + menuItemType: "toggle" + isChecked: timelineProperties.values.audioModeRole == 0 + onActivated: timelineProperties.values.audioModeRole = 0 + } + + XsMenuModelItem { + text: qsTr("Play Audio from Video Tracks") + menuPath: "Audio Mode" + menuItemPosition: 2.0 + menuModelName: timelineMenu.menu_model_name + panelContext: timelineMenu.panelContext + + menuItemType: "toggle" + isChecked: timelineProperties.values.audioModeRole == 1 + onActivated: timelineProperties.values.audioModeRole = 1 + } + XsMenuModelItem { menuItemType: "divider" menuPath: "" @@ -149,14 +175,27 @@ XsPopupMenu { Repeater { model: DelegateModel { model: embeddedPython.sequenceMenuModel - delegate: Item {XsMenuModelItem { - text: nameRole - menuPath: menuPathRole - menuItemPosition: (index*0.01)+80 - menuModelName: timelineMenu.menu_model_name - onActivated: embeddedPython.pyEvalFile(scriptPathRole) - panelContext: timelineMenu.panelContext - }} + delegate: Item { + XsHotkey { + id: hk + name: nameRole + description: nameRole + componentName: "Sequence Snippets" + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + sequence: menuHotKeyRole || null + context: ""+timelineMenu.panelContext + } + + XsMenuModelItem { + hotkeyUuid: hk.uuid + text: nameRole + menuPath: menuPathRole + menuItemPosition: (index*0.01)+80 + menuModelName: timelineMenu.menu_model_name + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + panelContext: timelineMenu.panelContext + } + } } } diff --git a/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineTrackMenu.qml b/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineTrackMenu.qml index 4e360735b..e95b0c20f 100644 --- a/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineTrackMenu.qml +++ b/ui/qml/xstudio/application/panels/timeline/menus/XsTimelineTrackMenu.qml @@ -6,6 +6,7 @@ import Qt.labs.qmlmodels import xstudio.qml.models 1.0 import xStudio 1.0 import xstudio.qml.helpers 1.0 +import xstudio.qml.viewport 1.0 XsPopupMenu { @@ -304,14 +305,27 @@ XsPopupMenu { Repeater { model: DelegateModel { model: embeddedPython.trackMenuModel - delegate: Item {XsMenuModelItem { - text: nameRole - menuPath: menuPathRole - menuItemPosition: (index*0.01)+32.5 - menuModelName: timelineMenu.menu_model_name - onActivated: embeddedPython.pyEvalFile(scriptPathRole) - panelContext: timelineMenu.panelContext - }} + delegate: Item { + XsHotkey { + id: hk + name: nameRole + description: nameRole + componentName: "Track Snippets" + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + sequence: menuHotKeyRole || null + context: timelineMenu.panelContext + } + + XsMenuModelItem { + hotkeyUuid: hk.uuid + text: nameRole + menuPath: menuPathRole + menuItemPosition: (index*0.01)+32.5 + menuModelName: timelineMenu.menu_model_name + onActivated: embeddedPython.pyEvalFile(scriptPathRole) + panelContext: timelineMenu.panelContext + } + } } } diff --git a/ui/qml/xstudio/application/panels/timeline/widgets/XsClipItem.qml b/ui/qml/xstudio/application/panels/timeline/widgets/XsClipItem.qml index 08744b4ce..74d616899 100644 --- a/ui/qml/xstudio/application/panels/timeline/widgets/XsClipItem.qml +++ b/ui/qml/xstudio/application/panels/timeline/widgets/XsClipItem.qml @@ -71,7 +71,7 @@ XsGradientRectangle { anchors.fill: parent anchors.leftMargin: 11 anchors.rightMargin: 5 - anchors.bottomMargin: 5 + anchors.bottomMargin: show_clip_handles ? 2 : 5 elide: Qt.ElideMiddle text: !isDragging ? name : dragValue > 0 ? "+" + dragValue : dragValue z:2 @@ -81,6 +81,42 @@ XsGradientRectangle { opacity: 0.8 } + property var inHandle: (start - availableStart) + property var outHandle: (availableDuration - duration) - inHandle + + XsText { + anchors.left: parent.left + anchors.top: parent.top + anchors.leftMargin: 4 + anchors.topMargin: 2 + verticalAlignment: Text.AlignTop + horizontalAlignment: Text.AlignLeft + text: "" + inHandle + visible: handlesVisisble + font.pixelSize: 8 + } + + XsText { + id: outFrames + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: 4 + anchors.topMargin: 2 + verticalAlignment: Text.AlignTop + horizontalAlignment: Text.AlignRight + text: "" + outHandle + visible: handlesVisisble + font.pixelSize: 8 + } + + TextMetrics { + id: handlesSize + font: outFrames.font + text: "" + outHandle + inHandle + } + + property bool handlesVisisble: show_clip_handles && (handlesSize.width < control.width-20 && (inHandle || outHandle)) + readonly property int dragWidth: 8 Rectangle { @@ -352,6 +388,20 @@ XsGradientRectangle { } } + // Indicates if clip has been edited since it was added to the timeline + // (or since timeline was loaded) + Rectangle { + width: 6 + height: 6 + radius: 3 + color: "cyan" + opacity: 0.7 + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 2 + visible: show_edit_indicator && (clipEditedStatusRole ? (control.width > 8) : false) + } + Rectangle { radius: 2 visible: showRolling diff --git a/ui/qml/xstudio/application/panels/timeline/widgets/XsTickWidget.qml b/ui/qml/xstudio/application/panels/timeline/widgets/XsTickWidget.qml index 7775d7a04..2d6113c3d 100644 --- a/ui/qml/xstudio/application/panels/timeline/widgets/XsTickWidget.qml +++ b/ui/qml/xstudio/application/panels/timeline/widgets/XsTickWidget.qml @@ -55,10 +55,14 @@ Rectangle { } onPressed: mouse => { - // here we make sure the viewport is attached to the correct - // playhead - we want the 'main' playhead of the timeline not the - // auxillary one which is used for showing individual clips - viewportCurrentMediaContainerIndex = timelineModel.rootIndex.parent + if (!multiTimelineMode) { + + // here we make sure the viewport is attached to the correct + // playhead - we want the 'main' playhead of the timeline not the + // auxillary one which is used for showing individual clips + viewportCurrentMediaContainerIndex = timelineModel.rootIndex.parent + + } // we ensure the timeline playhead is back in 'pinned' mode. This // means, regardless of the media selection, the playhead source @@ -112,4 +116,19 @@ Rectangle { width: 1 } } + + Rectangle { + anchors.fill: timelineTitle + color: trackBackground + anchors.margins: -1 + } + + XsText { + id: timelineTitle + anchors.centerIn: parent + text: timelineName + } + + + } \ No newline at end of file diff --git a/ui/qml/xstudio/assets/icons/horizontal_align_center.svg b/ui/qml/xstudio/assets/icons/horizontal_align_center.svg new file mode 100644 index 000000000..a4f466785 --- /dev/null +++ b/ui/qml/xstudio/assets/icons/horizontal_align_center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/qml/xstudio/assets/icons/hourglass.svg b/ui/qml/xstudio/assets/icons/hourglass.svg new file mode 100644 index 000000000..58aa0c13c --- /dev/null +++ b/ui/qml/xstudio/assets/icons/hourglass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/qml/xstudio/assets/icons/share.svg b/ui/qml/xstudio/assets/icons/share.svg new file mode 100644 index 000000000..ca160b4cc --- /dev/null +++ b/ui/qml/xstudio/assets/icons/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/qml/xstudio/assets/icons/share_off.svg b/ui/qml/xstudio/assets/icons/share_off.svg new file mode 100644 index 000000000..0ce29624f --- /dev/null +++ b/ui/qml/xstudio/assets/icons/share_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/qml/xstudio/menus/XsMenu.qml b/ui/qml/xstudio/menus/XsMenu.qml index 184dc3154..92c134878 100644 --- a/ui/qml/xstudio/menus/XsMenu.qml +++ b/ui/qml/xstudio/menus/XsMenu.qml @@ -46,8 +46,8 @@ XsPopup { function hideOtherSubMenus(widget) { - for (var i = 0; i < view.count; ++i) { - let item = view.itemAtIndex(i) + for (var i = 0; i < view.repeater.count; ++i) { + let item = view.repeater.itemAt(i) if (item != null && item != widget && typeof item.hideSubMenus != "undefined") { item.hideSubMenus() } @@ -65,20 +65,15 @@ XsPopup { } - XsListView { + Flickable { id: view - orientation: ListView.Vertical - spacing: 0 width: minWidth - height: maxMenuHeight(contentHeight) - contentHeight: contentItem.childrenRect.height + height: maxMenuHeight(layout.height) + contentHeight: layout.height contentWidth: minWidth - snapMode: ListView.SnapToItem clip: true - cacheBuffer: 1000 - ScrollBar.vertical: XsScrollBar { parent: view.parent id: scrollBar; @@ -111,133 +106,101 @@ XsPopup { } } - model: DelegateModel { - - // setting up the model and rootIndex like this causes us to - // iterate over the children of the node in the 'menu_model' that - // is found at 'menu_model_index'. Note that the delegate will - // be assigned a value 'index' which is its index in the list of - // children. - model: the_popup.menu_model - rootIndex: the_popup.menu_model_index - delegate: chooser - - DelegateChooser { - id: chooser - role: "menu_item_type" - - DelegateChoice { - roleValue: "button" - - XsMenuItemNew { - // again, we pass in the model to the menu item and - // step one level deeper into the tree by useing row=index - menu_model: the_popup.menu_model - menu_model_index: the_popup.menu_model.index( - index, // row = child index - 0, // column = 0 (always, we don't use columns) - the_popup.menu_model_index // the parent index into the model - ) - parent_menu: the_popup - width: view.width - indent: view.indent - enabled: menu_item_enabled - onMinWidthChanged: { - view.setMinWidth(minWidth) - } - onLeftIconSizeChanged: { - view.setIndent(leftIconSize) - } - } + property alias repeater: repeater - } + ColumnLayout { - DelegateChoice { - roleValue: "menu" - - XsMenuItemNew { - // again, we pass in the model to the menu item and - // step one level deeper into the tree by useing row=index - menu_model: the_popup.menu_model - menu_model_index: the_popup.menu_model.index( - index, // row = child index - 0, // column = 0 (always, we don't use columns) - the_popup.menu_model_index // the parent index into the model - ) - parent_menu: the_popup - width: view.width - indent: view.indent - onMinWidthChanged: { - view.setMinWidth(minWidth) - } - onLeftIconSizeChanged: { - view.setIndent(leftIconSize) - } - } - } + id: layout + spacing: 0 + Repeater { - DelegateChoice { - roleValue: "divider" - XsMenuDivider { - width: view.width - onMinWidthChanged: { - view.setMinWidth(minWidth) - } - } + id: repeater + model: DelegateModel { - } + // setting up the model and rootIndex like this causes us to + // iterate over the children of the node in the 'menu_model' that + // is found at 'menu_model_index'. Note that the delegate will + // be assigned a value 'index' which is its index in the list of + // children. + model: the_popup.menu_model + rootIndex: the_popup.menu_model_index + delegate: chooser - DelegateChoice { - roleValue: "multichoice" + DelegateChooser { + id: chooser + role: "menu_item_type" - XsMenuItemNew { - menu_model: the_popup.menu_model - menu_model_index: the_popup.menu_model.index(index, 0, the_popup.menu_model_index) + DelegateChoice { + roleValue: "button" - parent_menu: the_popup - width: view.width - indent: view.indent - onMinWidthChanged: { - view.setMinWidth(minWidth) - } - onLeftIconSizeChanged: { - view.setIndent(leftIconSize) - } + XsMenuItemNew { + // again, we pass in the model to the menu item and + // step one level deeper into the tree by useing row=index + menu_model: the_popup.menu_model + menu_model_index: the_popup.menu_model.index( + index, // row = child index + 0, // column = 0 (always, we don't use columns) + the_popup.menu_model_index // the parent index into the model + ) + parent_menu: the_popup + width: view.width + indent: view.indent + enabled: menu_item_enabled + onMinWidthChanged: { + view.setMinWidth(minWidth) + } + onLeftIconSizeChanged: { + view.setIndent(leftIconSize) + } + } - } + } - } + DelegateChoice { + roleValue: "menu" - DelegateChoice { + XsMenuItemNew { + // again, we pass in the model to the menu item and + // step one level deeper into the tree by useing row=index + menu_model: the_popup.menu_model + menu_model_index: the_popup.menu_model.index( + index, // row = child index + 0, // column = 0 (always, we don't use columns) + the_popup.menu_model_index // the parent index into the model + ) + parent_menu: the_popup + width: view.width + indent: view.indent + onMinWidthChanged: { + view.setMinWidth(minWidth) + } + onLeftIconSizeChanged: { + view.setIndent(leftIconSize) + } + } + } - roleValue: "radiogroup" + DelegateChoice { + roleValue: "divider" + XsMenuDivider { + width: view.width + onMinWidthChanged: { + view.setMinWidth(minWidth) + } + } - ColumnLayout { + } - width: view.width - spacing: 0 - id: layout - property var idx: index - Repeater { + DelegateChoice { + roleValue: "multichoice" - // we can optionally drive the menu selection with - // 'choice_ids' - this allows us to handle the case - // where there are duplicate names in 'choices' but - // they really mean different - model: choices_ids ? choices_ids : choices - XsMenuItemToggle { - actualValue: choices_ids ? choices_ids[index] : choices[index] - label: choices[index] - isRadioButton: true - radioSelectedChoice: current_choice + XsMenuItemNew { menu_model: the_popup.menu_model - menu_model_index: the_popup.menu_model.index(layout.idx, 0, the_popup.menu_model_index) + menu_model_index: the_popup.menu_model.index(index, 0, the_popup.menu_model_index) + parent_menu: the_popup - onClicked: { - current_choice = actualValue - the_popup.closeAll() - } width: view.width + indent: view.indent onMinWidthChanged: { view.setMinWidth(minWidth) } @@ -246,84 +209,124 @@ XsPopup { } } + } - } - } + DelegateChoice { - DelegateChoice { - roleValue: "toggle" + roleValue: "radiogroup" - XsMenuItemToggle { - menu_model: the_popup.menu_model - menu_model_index: the_popup.menu_model.index(index, 0, the_popup.menu_model_index) - parent_menu: the_popup + ColumnLayout { - onClicked: { - menu_model.nodeActivated(menu_model_index, "clicked", helpers.contextPanel(the_popup)) - // if(mouse.modifiers == Qt.NoModifier) - // the_popup.closeAll() - } + width: view.width + spacing: 0 + id: layout + property var idx: index + Repeater { + + // we can optionally drive the menu selection with + // 'choice_ids' - this allows us to handle the case + // where there are duplicate names in 'choices' but + // they really mean different + model: choices_ids ? choices_ids : choices + XsMenuItemToggle { + actualValue: choices_ids ? choices_ids[index] : choices[index] + label: choices[index] + isRadioButton: true + radioSelectedChoice: current_choice + menu_model: the_popup.menu_model + menu_model_index: the_popup.menu_model.index(layout.idx, 0, the_popup.menu_model_index) + parent_menu: the_popup + onClicked: { + current_choice = actualValue + the_popup.closeAll() + } + width: view.width + onMinWidthChanged: { + view.setMinWidth(minWidth) + } + onLeftIconSizeChanged: { + view.setIndent(leftIconSize) + } + + } + } + } - width: view.width - onMinWidthChanged: { - view.setMinWidth(minWidth) - } - onLeftIconSizeChanged: { - view.setIndent(leftIconSize) } - } + DelegateChoice { + roleValue: "toggle" - } + XsMenuItemToggle { + menu_model: the_popup.menu_model + menu_model_index: the_popup.menu_model.index(index, 0, the_popup.menu_model_index) + parent_menu: the_popup + + onClicked: { + menu_model.nodeActivated(menu_model_index, "clicked", helpers.contextPanel(the_popup)) + // if(mouse.modifiers == Qt.NoModifier) + // the_popup.closeAll() + } - DelegateChoice { - roleValue: "toggle_settings" + width: view.width + onMinWidthChanged: { + view.setMinWidth(minWidth) + } + onLeftIconSizeChanged: { + view.setIndent(leftIconSize) + } - XsMenuItemToggleWithSettings { - menu_model: the_popup.menu_model - menu_model_index: the_popup.menu_model.index(index, 0, the_popup.menu_model_index) + } - parent_menu: the_popup - width: view.width - isChecked: is_checked - onClicked:{ - is_checked = !is_checked - // the_popup.closeAll() - } - onMinWidthChanged: { - view.setMinWidth(minWidth) - } - onLeftIconSizeChanged: { - view.setIndent(leftIconSize) } - } + DelegateChoice { + roleValue: "toggle_settings" - } + XsMenuItemToggleWithSettings { + menu_model: the_popup.menu_model + menu_model_index: the_popup.menu_model.index(index, 0, the_popup.menu_model_index) + + parent_menu: the_popup + width: view.width + isChecked: is_checked + onClicked:{ + is_checked = !is_checked + // the_popup.closeAll() + } + onMinWidthChanged: { + view.setMinWidth(minWidth) + } + onLeftIconSizeChanged: { + view.setIndent(leftIconSize) + } - DelegateChoice { - roleValue: "custom" + } - XsMenuItemCustom { - menu_model: the_popup.menu_model - menu_model_index: the_popup.menu_model.index(index, 0, the_popup.menu_model_index) - parent_menu: the_popup - width: view.width - onMinWidthChanged: { - view.setMinWidth(minWidth) - } - onLeftIconSizeChanged: { - view.setIndent(leftIconSize) } - } + DelegateChoice { + roleValue: "custom" - } + XsMenuItemCustom { + menu_model: the_popup.menu_model + menu_model_index: the_popup.menu_model.index(index, 0, the_popup.menu_model_index) + parent_menu: the_popup + width: view.width + onMinWidthChanged: { + view.setMinWidth(minWidth) + } + onLeftIconSizeChanged: { + view.setIndent(leftIconSize) + } + } + } + } + } } } - } } diff --git a/ui/qml/xstudio/menus/XsMenuMultiChoice.qml b/ui/qml/xstudio/menus/XsMenuMultiChoice.qml index a7c4fbb54..4ab4582d3 100644 --- a/ui/qml/xstudio/menus/XsMenuMultiChoice.qml +++ b/ui/qml/xstudio/menus/XsMenuMultiChoice.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls.Basic +import QtQuick.Layouts import xStudio 1.0 import xstudio.qml.models 1.0 @@ -10,10 +11,9 @@ XsPopup { // a list of strings 'choices' id: the_popup + height: view.height+ (topPadding+bottomPadding) width: view.width - implicitHeight: view.height+ (topPadding+bottomPadding) - property var menu_model property var menu_model_index property var parent_menu @@ -35,18 +35,23 @@ XsPopup { property var _currentChoice: typeof current_choice !== "undefined" ? current_choice : typeof value !== "undefined" ? value : "" property var _choices_enabled: typeof combo_box_options_enabled !== "undefined" ? combo_box_options_enabled : [] - XsListView { + property real minWidth: 20 + + // awkward solution to make all items in the list view the + // same width ... the width of the widest item in the view! + function setMinWidth(mw) { + if (mw > minWidth) { + minWidth = mw + } + } + + Flickable { id: view - orientation: ListView.Vertical - spacing: 0 width: minWidth - height: maxMenuHeight(contentHeight) - contentHeight: contentItem.childrenRect.height + height: maxMenuHeight(layout.height) + contentHeight: layout.height contentWidth: minWidth - snapMode: ListView.SnapOneItem - // currentIndex: -1 - property real minWidth: 20 clip: true ScrollBar.vertical: XsScrollBar { @@ -63,6 +68,9 @@ XsPopup { animatedGlow: visible } + property real minWidth: 20 + property real indent: 0 + // awkward solution to make all items in the list view the // same width ... the width of the widest item in the view! function setMinWidth(mw) { @@ -71,42 +79,60 @@ XsPopup { } } - model: DelegateModel { + // awkward solution to make all items indent to the maximum indent + function setIndent(i) { + if (i > indent) { + indent = i + } + } - model: _choices + property alias repeater: repeater - delegate: XsMenuItemToggle{ + ColumnLayout { - // isRadioButton: true - // radioSelectedChoice: current_choice - // label: choices[index] + id: layout + spacing: 0 + Repeater { - // onChecked: { - // label= choices[index] - // current_choice = label - // radioSelectedChoice = current_choice - // } + id: repeater + model: DelegateModel { - isRadioButton: true - actualValue: _choices[index] - radioSelectedChoice: _currentChoice - onClicked:{ - if (typeof current_choice!== "undefined") { - current_choice = name - } else if (value) { - value = name - } - the_popup.closeAll() - } + model: _choices - parent_menu: the_popup - width: view.minWidth - onMinWidthChanged: { - view.setMinWidth(minWidth) - } - enabled: (is_enabled && index >= 0) ?_choices_enabled.length > index ? _choices_enabled[index] : true : false + delegate: XsMenuItemToggle{ + + // isRadioButton: true + // radioSelectedChoice: current_choice + // label: choices[index] + + // onChecked: { + // label= choices[index] + // current_choice = label + // radioSelectedChoice = current_choice + // } - property var name: _choices[index] + isRadioButton: true + actualValue: _choices[index] + radioSelectedChoice: _currentChoice + onClicked:{ + if (typeof current_choice!== "undefined") { + current_choice = name + } else if (value) { + value = name + } + the_popup.closeAll() + } + + parent_menu: the_popup + width: view.minWidth + onMinWidthChanged: { + view.setMinWidth(minWidth) + } + enabled: (is_enabled && index >= 0) ?_choices_enabled.length > index ? _choices_enabled[index] : true : false + + property var name: _choices[index] + } + } } diff --git a/ui/qml/xstudio/qml.qrc b/ui/qml/xstudio/qml.qrc index 13d3481da..6f4b539d9 100644 --- a/ui/qml/xstudio/qml.qrc +++ b/ui/qml/xstudio/qml.qrc @@ -106,6 +106,7 @@ application/panels/timeline/menus/XsTimelineClipMenu.qml application/panels/timeline/menus/XsTimelineMenu.qml application/panels/timeline/XsTimelinePanel.qml + application/panels/timeline/XsTimelinePanelParent.qml application/panels/timeline/menus/XsTimelineTrackMenu.qml application/panels/python/XsPythonPanel.qml @@ -309,9 +310,9 @@ + assets/icons/account_tree.svg assets/icons/ad_group.svg assets/icons/add.svg - assets/icons/account_tree.svg assets/icons/arrow_circle_up.svg assets/icons/arrow_drop_down.svg assets/icons/arrow_range.svg @@ -360,15 +361,17 @@ assets/icons/filter_none.svg assets/icons/fit_screen.svg assets/icons/fit_select.svg - assets/icons/format_size.svg assets/icons/folder.svg assets/icons/folder_open.svg + assets/icons/format_size.svg assets/icons/frame_inspect.svg assets/icons/functions.svg assets/icons/grid_view.svg assets/icons/help.svg - assets/icons/home.svg assets/icons/history.svg + assets/icons/home.svg + assets/icons/horizontal_align_center.svg + assets/icons/hourglass.svg assets/icons/image.svg assets/icons/imagesmode.svg assets/icons/info.svg @@ -403,8 +406,8 @@ assets/icons/output.svg assets/icons/pan.svg assets/icons/pause.svg - assets/icons/person.svg assets/icons/pending.svg + assets/icons/person.svg assets/icons/photo_camera.svg assets/icons/play_arrow.svg assets/icons/play_circle.svg @@ -414,9 +417,9 @@ assets/icons/radio_button_unchecked.svg assets/icons/rectangle.svg assets/icons/redo.svg + assets/icons/refresh.svg assets/icons/repartition.svg assets/icons/repeat.svg - assets/icons/refresh.svg assets/icons/reset_image.svg assets/icons/reset_tv.svg assets/icons/restart.svg @@ -427,6 +430,8 @@ assets/icons/search_off.svg assets/icons/select.svg assets/icons/settings.svg + assets/icons/share.svg + assets/icons/share_off.svg assets/icons/shield_person.svg assets/icons/skip_next.svg assets/icons/skip_previous.svg @@ -438,8 +443,8 @@ assets/icons/splitscreen_add.svg assets/icons/stacks.svg assets/icons/sticky_note.svg - assets/icons/stylus_note.svg assets/icons/stylus.svg + assets/icons/stylus_note.svg assets/icons/swap_horiz.svg assets/icons/swap_vert.svg assets/icons/sync.svg diff --git a/ui/qml/xstudio/widgets/XsComboBox.qml b/ui/qml/xstudio/widgets/XsComboBox.qml index af0897016..2d779f9e8 100644 --- a/ui/qml/xstudio/widgets/XsComboBox.qml +++ b/ui/qml/xstudio/widgets/XsComboBox.qml @@ -157,14 +157,39 @@ ComboBox { id: widget XsPopup{ id: popupOptions width: widget.width - height: Math.min(contentItem.implicitHeight, widget.Window.height - topMargin - bottomMargin ) padding: 1 - y: widget.height + + // Room above and below the control inside the window, measured + // each time the popup opens. + property real spaceAbove: 0 + property real spaceBelow: widget.Window.height + + // Open below the control if the full list fits there. Otherwise + // flip above if it fits there. Otherwise open on the side with + // more room, capped to that room, and let the scrollbar do the rest. + function place() { + var win = widget.Window.window + if (win && win.contentItem) { + var pos = widget.mapToItem(win.contentItem, 0, 0) + spaceAbove = pos.y + spaceBelow = win.contentItem.height - pos.y - widget.height + } + var wanted = contentItem.implicitHeight + var above = wanted > spaceBelow && (wanted <= spaceAbove || spaceAbove > spaceBelow) + var h = Math.max(0, Math.min(wanted, above ? spaceAbove : spaceBelow)) + // Set y before height: XsPopup re-runs the window level + // repositioning on height changes using the current y. + y = above ? -h : widget.height + height = h + } + + onAboutToShow: place() contentItem: ListView { id: listView clip: true implicitHeight: contentHeight + (popupOptions.topPadding + popupOptions.bottomPadding) + onImplicitHeightChanged: if (popupOptions.visible) popupOptions.place() model: widget.popup.visible ? widget.delegateModel: null currentIndex: widget.highlightedIndex snapMode: ListView.SnapToItem diff --git a/ui/qml/xstudio/widgets/XsComboBoxEditable.qml b/ui/qml/xstudio/widgets/XsComboBoxEditable.qml index 140808502..9f64a1df3 100644 --- a/ui/qml/xstudio/widgets/XsComboBoxEditable.qml +++ b/ui/qml/xstudio/widgets/XsComboBoxEditable.qml @@ -17,7 +17,7 @@ XsComboBox{ XsPrimaryButton{ id: clearBtn imgSrc: "qrc:/icons/close.svg" - visible: currentIndex !== -1 && (hovered || popup.opened ) + visible: currentIndex !== -1 && (parent.hovered || popup.opened ) width: height height: parent.height - XsStyleSheet.panelPadding diff --git a/ui/qml/xstudio/widgets/XsIntegerAttrControl.qml b/ui/qml/xstudio/widgets/XsIntegerAttrControl.qml index 16ee6c6ce..9cfbd13c0 100644 --- a/ui/qml/xstudio/widgets/XsIntegerAttrControl.qml +++ b/ui/qml/xstudio/widgets/XsIntegerAttrControl.qml @@ -82,6 +82,8 @@ RowLayout { text: root.text fromValue: integer_min toValue: integer_max + value: model.value + onValueEdited: (newValue) => { model.value = newValue } Layout.fillWidth: true Layout.fillHeight: true } diff --git a/ui/qml/xstudio/widgets/XsIntegerValueControl.qml b/ui/qml/xstudio/widgets/XsIntegerValueControl.qml index ccdddb1b0..0027451db 100644 --- a/ui/qml/xstudio/widgets/XsIntegerValueControl.qml +++ b/ui/qml/xstudio/widgets/XsIntegerValueControl.qml @@ -16,14 +16,22 @@ Control Layout.fillWidth: true Layout.fillHeight: true + // The value shown and scrubbed. Bind it from the caller; the control never + // writes it itself, it emits valueEdited(newValue) and the caller pushes + // that to its model, whose echo comes back through the binding. + property var value + signal valueEdited(var newValue) + readonly property bool bound: value !== undefined && value !== null && !isNaN(value) + property bool isPressed: false //mouseArea.containsPress property bool isMouseHovered: mouseArea.containsMouse property string text property string shortText: text.substring(0,3) property int fromValue: 0 property int toValue: 100 - property int defaultValue: toValue - property int prevValue: defaultValue/2 + // Optional: double-click toggles between this and the previous value. + // No default means double-click does nothing. + property var defaultValue: undefined property var stepSize: 0.25 property alias valueText: valueDiv.text @@ -49,6 +57,15 @@ Control focusPolicy: Qt.NoFocus clip: true + function edit(newValue) { + newValue = Math.min(toValue, Math.max(newValue, fromValue)) + if (newValue !== value) valueEdited(newValue) + } + function ignoreUnbound(what) { + if (typeof helpers !== "undefined") + helpers.logWarning("XsIntegerValueControl '" + text + "': " + what + " ignored, value is unbound") + } + onWidthChanged: { if(width < shortThresholdWidth) { isShortened = true @@ -137,18 +154,10 @@ Control font.family: XsStyleSheet.fontFamily onAccepted:{ - // if(currentTool != "Erase"){ //#todo - if(parseInt(text) >= toValue) { - value = toValue - } - else if(parseInt(text) <= fromValue) { - value = fromValue - } - else { - value = parseInt(text) - } - selectAll() - // } + if (!bound) { ignoreUnbound("text entry"); return } + var v = parseInt(text) + if (!isNaN(v)) edit(v) + selectAll() } } @@ -162,22 +171,16 @@ Control propagateComposedEvents: true property real mouseXOnPress: 0 - property int valueOnPress: defaultValue - property int lastValue: defaultValue + property real valueOnPress: 0 + property real lastValue: 0 onMouseXChanged: { - - if(pressed) { - value = Math.min( - toValue, - Math.max( - valueOnPress + (mouseX - mouseXOnPress)*stepSize, - fromValue) - ) - } + if (pressed && bound) + edit(valueOnPress + (mouseX - mouseXOnPress)*stepSize) } onPressed: { + if (!bound) { ignoreUnbound("drag"); return } mouseXOnPress = mouseX if (value != defaultValue) { lastValue = value @@ -186,12 +189,9 @@ Control } onDoubleClicked: { - if(value == defaultValue){ - value = lastValue - } - else{ - value = defaultValue - } + if (!bound) { ignoreUnbound("double-click"); return } + if (defaultValue === undefined) return + edit(value == defaultValue ? lastValue : defaultValue) } } } diff --git a/ui/qml/xstudio/widgets/XsTextWithComboBox.qml b/ui/qml/xstudio/widgets/XsTextWithComboBox.qml index 2b0cd17be..ac201c45a 100644 --- a/ui/qml/xstudio/widgets/XsTextWithComboBox.qml +++ b/ui/qml/xstudio/widgets/XsTextWithComboBox.qml @@ -15,6 +15,8 @@ Item{ id: widget property alias model: valueDiv.model property alias currentIndex: valueDiv.currentIndex + signal activated(index: int) + RowLayout{ anchors.fill: parent @@ -32,10 +34,11 @@ Item{ id: widget XsComboBox{ id: valueDiv Layout.fillWidth: true Layout.fillHeight: true + onActivated: widget.activated(currentIndex) - onCurrentIndexChanged: { - widget.currentIndexChanged() - } + // onCurrentIndexChanged: { + // widget.currentIndexChanged() + // } } } diff --git a/ui/qml/xstudio/widgets/XsTextWithInputField.qml b/ui/qml/xstudio/widgets/XsTextWithInputField.qml index 68e37776b..7fa636cca 100644 --- a/ui/qml/xstudio/widgets/XsTextWithInputField.qml +++ b/ui/qml/xstudio/widgets/XsTextWithInputField.qml @@ -8,6 +8,7 @@ Item{ id: widget property alias label: labelDiv.text property alias value: valueDiv.text + property alias text: valueDiv.text // property alias hintingPreference: valueDiv.font.hintingPreference property alias echoMode: valueDiv.echoMode diff --git a/ui/qml/xstudio/widgets/indicators/XsNotification.qml b/ui/qml/xstudio/widgets/indicators/XsNotification.qml index 517139e6e..703abd1d9 100644 --- a/ui/qml/xstudio/widgets/indicators/XsNotification.qml +++ b/ui/qml/xstudio/widgets/indicators/XsNotification.qml @@ -9,13 +9,14 @@ Item { property alias text: tt.text property string type: "INFO" property real percentage: 0.0 + property bool hovered: hover.hovered XsToolTip { id: tt delay: 0 x: -width/2 y: parent.height - visible: hover.hovered + visible: parent.hovered } HoverHandler { diff --git a/ui/qml/xstudio/xStudio/qmldir b/ui/qml/xstudio/xStudio/qmldir index 1bd1d2714..c0ca80264 100644 --- a/ui/qml/xstudio/xStudio/qmldir +++ b/ui/qml/xstudio/xStudio/qmldir @@ -48,6 +48,7 @@ XsTimeline 1.0 ../application/panels/timeline/XsTimeline.qml XsTimelineClipMenu 1.0 ../application/panels/timeline/menus/XsTimelineClipMenu.qml XsTimelineMenu 1.0 ../application/panels/timeline/menus/XsTimelineMenu.qml XsTimelinePanel 1.0 ../application/panels/timeline/XsTimelinePanel.qml +XsTimelinePanelParent 1.0 ../application/panels/timeline/XsTimelinePanelParent.qml XsTimelineTrackMenu 1.0 ../application/panels/timeline/menus/XsTimelineTrackMenu.qml XsDelegateAudioTrack 1.0 ../application/panels/timeline/delegates/XsDelegateAudioTrack.qml diff --git a/xStudioConfig.cmake.in b/xStudioConfig.cmake.in index 7ee2ce94c..a91db762e 100644 --- a/xStudioConfig.cmake.in +++ b/xStudioConfig.cmake.in @@ -8,6 +8,13 @@ get_filename_component(xStudio_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH) set(xStudio_INCLUDE_DIRS "@CONF_INCLUDE_DIRS@") +# Public dependencies exposed on xStudio target interfaces (e.g. xstudio::http_client +# exposes OpenSSL::SSL / ZLIB::ZLIB via cpp-httplib in its public headers). Resolve +# their imported targets so downstream find_package(xStudio) consumers don't have to. +include(CMakeFindDependencyMacro) +find_dependency(OpenSSL) +find_dependency(ZLIB) + # Our library dependencies (contains definitions for IMPORTED targets) if(NOT TARGET foo AND NOT xStudio_BINARY_DIR) include("${xStudio_CMAKE_DIR}/xStudioTargets.cmake")