diff --git a/include/xstudio/ui/canvas/stroke.hpp b/include/xstudio/ui/canvas/stroke.hpp index 24d452b4b..d09c4b36d 100644 --- a/include/xstudio/ui/canvas/stroke.hpp +++ b/include/xstudio/ui/canvas/stroke.hpp @@ -77,6 +77,8 @@ namespace ui { bool fade(const float fade_amount); [[nodiscard]] float opacity() const { return _opacity; } + void set_opacity(const float o) { _opacity = o; } + void set_colour(const utility::ColourTriplet &c) { _colour = c; } [[nodiscard]] float thickness() const { return _thickness; } [[nodiscard]] float softness() const { return _softness; } [[nodiscard]] float size_sensitivity() const { return _size_sensitivity; } diff --git a/src/plugin/viewport_overlay/CMakeLists.txt b/src/plugin/viewport_overlay/CMakeLists.txt index 25e514562..08ad2a7d4 100644 --- a/src/plugin/viewport_overlay/CMakeLists.txt +++ b/src/plugin/viewport_overlay/CMakeLists.txt @@ -2,5 +2,6 @@ add_src_and_test(basic_viewport_mask) add_src_and_test(annotations) add_src_and_test(audio_waveform) add_src_and_test(media_metadata_hud) +add_src_and_test(annotation_onion_skin) build_studio_plugins("${STUDIO_PLUGINS}") diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/CMakeLists.txt b/src/plugin/viewport_overlay/annotation_onion_skin/src/CMakeLists.txt new file mode 100644 index 000000000..7efd0c18a --- /dev/null +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/CMakeLists.txt @@ -0,0 +1,15 @@ + +SET(LINK_DEPS + xstudio::module + xstudio::plugin_manager + xstudio::ui::opengl::viewport + Imath::Imath +) + +find_package(Imath) + +create_plugin_with_alias( + annotation_onion_skin + xstudio::viewport::annotation_onion_skin + ${XSTUDIO_GLOBAL_VERSION} + "${LINK_DEPS}") diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.cpp b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.cpp new file mode 100644 index 000000000..71b2148df --- /dev/null +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.cpp @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "onion_skin_plugin.hpp" +#include "onion_skin_render_data.hpp" +#include "onion_skin_renderer.hpp" +#include "xstudio/media_reader/image_buffer.hpp" +#include "xstudio/bookmark/bookmark.hpp" +#include "xstudio/plugin_manager/plugin_base.hpp" +#include "xstudio/utility/blind_data.hpp" + +#include +#include +#include + +using namespace xstudio; +using namespace xstudio::ui::viewport; + +OnionSkinPlugin::OnionSkinPlugin( + caf::actor_config &cfg, const utility::JsonStore &init_settings) + : plugin::HUDPluginBase(cfg, "Annotation Onion Skin", init_settings, 10.0f) { + + frames_before_ = add_integer_attribute("Frames Before", "Before", 3, 0, 20); + add_hud_settings_attribute(frames_before_); + frames_before_->set_tool_tip( + "Maximum frame distance to look back for annotations"); + frames_before_->set_redraw_viewport_on_change(true); + + frames_after_ = add_integer_attribute("Frames After", "After", 3, 0, 20); + add_hud_settings_attribute(frames_after_); + frames_after_->set_tool_tip( + "Maximum frame distance to look ahead for annotations"); + frames_after_->set_redraw_viewport_on_change(true); + + base_opacity_ = + add_float_attribute("Base Opacity", "Opacity", 0.4f, 0.05f, 1.0f, 0.05f); + add_hud_settings_attribute(base_opacity_); + base_opacity_->set_tool_tip("Opacity of the nearest neighboring annotation"); + base_opacity_->set_redraw_viewport_on_change(true); + + opacity_falloff_ = + add_float_attribute("Opacity Falloff", "Falloff", 0.5f, 0.1f, 1.0f, 0.05f); + add_hud_settings_attribute(opacity_falloff_); + opacity_falloff_->set_tool_tip( + "Multiplier applied per frame step further from current frame"); + opacity_falloff_->set_redraw_viewport_on_change(true); + + use_original_colours_ = add_boolean_attribute( + "Use Original Colours", "Orig Colours", false); + add_hud_settings_attribute(use_original_colours_); + use_original_colours_->set_tool_tip( + "When enabled, keep annotation colours and only reduce opacity. " + "When disabled, tint with Previous/Next colours."); + use_original_colours_->set_redraw_viewport_on_change(true); + + past_tint_ = add_colour_attribute( + "Previous Tint", "Prev Tint", utility::ColourTriplet(1.0f, 0.3f, 0.3f)); + add_hud_settings_attribute(past_tint_); + past_tint_->set_tool_tip("Tint colour for annotations from previous frames"); + past_tint_->set_redraw_viewport_on_change(true); + + future_tint_ = add_colour_attribute( + "Next Tint", "Next Tint", utility::ColourTriplet(0.3f, 1.0f, 0.3f)); + add_hud_settings_attribute(future_tint_); + future_tint_->set_tool_tip("Tint colour for annotations from future frames"); + future_tint_->set_redraw_viewport_on_change(true); + + add_hud_description( + "Shows annotations from neighboring frames as semi-transparent, " + "color-tinted overlays on the current frame."); + + frames_before_->set_preference_path("/plugin/annotation_onion_skin/frames_before"); + frames_after_->set_preference_path("/plugin/annotation_onion_skin/frames_after"); + base_opacity_->set_preference_path("/plugin/annotation_onion_skin/base_opacity"); + opacity_falloff_->set_preference_path("/plugin/annotation_onion_skin/opacity_falloff"); + use_original_colours_->set_preference_path("/plugin/annotation_onion_skin/use_original_colours"); + past_tint_->set_preference_path("/plugin/annotation_onion_skin/past_tint"); + future_tint_->set_preference_path("/plugin/annotation_onion_skin/future_tint"); +} + +plugin::ViewportOverlayRendererPtr +OnionSkinPlugin::make_overlay_renderer(const std::string & /*viewport_name*/) { + return plugin::ViewportOverlayRendererPtr(new OnionSkinRenderer()); +} + +utility::BlindDataObjectPtr OnionSkinPlugin::onscreen_render_data( + const media_reader::ImageBufPtr &image, + const std::string & /*viewport_name*/, + const utility::Uuid & /*playhead_uuid*/, + const bool /*is_hero_image*/, + const bool /*images_are_in_grid_layout*/) const { + + if (!visible() || !image) + return {}; + + const int current_frame = image.playhead_logical_frame(); + const int range_before = static_cast(frames_before_->value()); + const int range_after = static_cast(frames_after_->value()); + const float base_opac = base_opacity_->value(); + const float falloff = opacity_falloff_->value(); + const bool orig_colours = use_original_colours_->value(); + const auto &prev_colour = past_tint_->value(); + const auto &next_colour = future_tint_->value(); + + if (range_before == 0 && range_after == 0) + return {}; + + // ── Update bookmark cache ── + // image.bookmarks() carries bookmarks covering the current frame. + // We cache them keyed by logical frame to find neighbors later. + // + // Invalidation: when revisiting a frame, if its bookmarks changed + // (different UUIDs or count), we clear the entire cache. This handles + // bookmark deletion, media changes, and bookmark additions. + const auto &frame_bookmarks = image.bookmarks(); + { + std::lock_guard lock(cache_mutex_); + + auto it = frame_bookmark_cache_.find(current_frame); + if (it != frame_bookmark_cache_.end()) { + bool changed = (it->second.size() != frame_bookmarks.size()); + if (!changed) { + for (size_t i = 0; i < it->second.size(); ++i) { + if (it->second[i]->detail_.uuid_ != + frame_bookmarks[i]->detail_.uuid_) { + changed = true; + break; + } + } + } + if (changed) { + frame_bookmark_cache_.clear(); + } + } + + if (!frame_bookmarks.empty()) { + frame_bookmark_cache_[current_frame] = frame_bookmarks; + } else { + frame_bookmark_cache_.erase(current_frame); + } + } + + // Collect current frame's annotation pointers — skip these when + // walking neighbors (same annotation spans multiple frames). + std::set current_annotations; + for (const auto &bm : frame_bookmarks) { + if (bm && bm->annotation_ && bm->annotation_->user_data()) + current_annotations.insert(bm->annotation_->user_data()); + } + + // ── Helpers ── + auto tint_colour = [](const utility::ColourTriplet &c, + const utility::ColourTriplet &tint) -> utility::ColourTriplet { + return {c.r * tint.r, c.g * tint.g, c.b * tint.b}; + }; + + auto make_canvas_copy = [&](const ui::canvas::Canvas &src, float opacity, + const utility::ColourTriplet &tint, + bool keep_original) -> ui::canvas::Canvas { + ui::canvas::Canvas out(src); + for (auto it = out.begin(); it != out.end(); ++it) { + auto item = *it; + std::visit( + [&](auto &v) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + v.set_opacity(v.opacity() * opacity); + if (!keep_original) + v.set_colour(tint_colour(v.colour(), tint)); + } else if constexpr (std::is_same_v) { + v.set_opacity(v.opacity() * opacity); + v.set_bg_opacity(v.background_opacity() * opacity); + if (!keep_original) + v.set_colour(tint_colour(v.colour(), tint)); + } else { + v.opacity *= opacity; + if (!keep_original) + v.colour = tint_colour(v.colour, tint); + } + }, + item); + out.overwrite_item(it, item); + } + return out; + }; + + // Opacity falls off with distance: nearest = base_opac, farther = less. + auto compute_opacity = [&](int distance) -> float { + return base_opac * std::pow(falloff, static_cast(distance - 1)); + }; + + // ── Find neighbor annotations from cache (distance-bounded) ── + struct Candidate { + const ui::canvas::Canvas *canvas; + int abs_distance; + float opacity; + utility::ColourTriplet tint; + }; + std::vector candidates; + + { + std::lock_guard lock(cache_mutex_); + + // Walk backward — stop when distance exceeds range_before. + if (range_before > 0) { + auto it = frame_bookmark_cache_.lower_bound(current_frame); + if (it != frame_bookmark_cache_.begin()) { + auto pit = it; + while (pit != frame_bookmark_cache_.begin()) { + --pit; + int dist = current_frame - pit->first; + if (dist > range_before) + break; + for (const auto &bm : pit->second) { + if (!bm || !bm->annotation_ || !bm->annotation_->user_data()) + continue; + const auto *canvas = static_cast( + bm->annotation_->user_data()); + if (!canvas || canvas->empty()) + continue; + if (current_annotations.count(canvas)) + continue; + candidates.push_back( + {canvas, dist, compute_opacity(dist), prev_colour}); + break; + } + } + } + } + + // Walk forward — stop when distance exceeds range_after. + if (range_after > 0) { + auto it = frame_bookmark_cache_.upper_bound(current_frame); + while (it != frame_bookmark_cache_.end()) { + int dist = it->first - current_frame; + if (dist > range_after) + break; + for (const auto &bm : it->second) { + if (!bm || !bm->annotation_ || !bm->annotation_->user_data()) + continue; + const auto *canvas = static_cast( + bm->annotation_->user_data()); + if (!canvas || canvas->empty()) + continue; + if (current_annotations.count(canvas)) + continue; + candidates.push_back( + {canvas, dist, compute_opacity(dist), next_colour}); + break; + } + ++it; + } + } + } + + if (candidates.empty()) + return {}; + + // Render farthest first so closest onion skin draws on top. + std::sort(candidates.begin(), candidates.end(), + [](const auto &a, const auto &b) { return a.abs_distance > b.abs_distance; }); + + std::vector canvases; + canvases.reserve(candidates.size()); + for (const auto &c : candidates) { + canvases.push_back(make_canvas_copy(*c.canvas, c.opacity, c.tint, orig_colours)); + } + + return std::make_shared(std::move(canvases)); +} + + +extern "C" { +plugin_manager::PluginFactoryCollection *plugin_factory_collection_ptr() { + return new plugin_manager::PluginFactoryCollection( + std::vector>( + {std::make_shared>( + OnionSkinPlugin::PLUGIN_UUID, + "AnnotationOnionSkin", + plugin_manager::PluginFlags::PF_HEAD_UP_DISPLAY | + plugin_manager::PluginFlags::PF_VIEWPORT_OVERLAY, + true, + "RodeoFX", + "Annotation Onion Skinning Overlay")})); +} +} diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.hpp b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.hpp new file mode 100644 index 000000000..ed1fc6fa4 --- /dev/null +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.hpp @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include "xstudio/bookmark/bookmark.hpp" +#include "xstudio/plugin_manager/hud_plugin.hpp" +#include "xstudio/ui/canvas/canvas.hpp" + +namespace xstudio { +namespace ui { + namespace viewport { + + class OnionSkinRenderer; + + class OnionSkinPlugin : public plugin::HUDPluginBase { + public: + inline static const utility::Uuid PLUGIN_UUID{ + "b7e3a1c0-5d4f-4e8b-9a2c-1f6d8e0b3c5a"}; + + OnionSkinPlugin( + caf::actor_config &cfg, const utility::JsonStore &init_settings); + + ~OnionSkinPlugin() override = default; + + protected: + utility::BlindDataObjectPtr onscreen_render_data( + const media_reader::ImageBufPtr &image, + const std::string &viewport_name, + const utility::Uuid &playhead_uuid, + const bool is_hero_image, + const bool images_are_in_grid_layout) const override; + + plugin::ViewportOverlayRendererPtr + make_overlay_renderer(const std::string &viewport_name) override; + + private: + module::IntegerAttribute *frames_before_; + module::IntegerAttribute *frames_after_; + module::FloatAttribute *base_opacity_; + module::FloatAttribute *opacity_falloff_; + module::BooleanAttribute *use_original_colours_; + module::ColourAttribute *past_tint_; + module::ColourAttribute *future_tint_; + + // Bookmark cache: built from image.bookmarks() as user scrubs. + // Invalidated when bookmarks change (detected by comparing + // bookmark UUIDs for revisited frames). + mutable std::mutex cache_mutex_; + mutable std::map frame_bookmark_cache_; + }; + + } // namespace viewport +} // namespace ui +} // namespace xstudio diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_render_data.hpp b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_render_data.hpp new file mode 100644 index 000000000..1bfcf3f56 --- /dev/null +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_render_data.hpp @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "xstudio/ui/canvas/canvas.hpp" +#include "xstudio/utility/blind_data.hpp" + + +namespace xstudio { +namespace ui { + namespace viewport { + + // Each neighbor canvas has opacity and tint baked into its items, + // so it can be rendered directly with no FBO compositing. + class OnionSkinRenderData : public utility::BlindDataObject { + public: + OnionSkinRenderData() = default; + explicit OnionSkinRenderData(std::vector c) + : canvases(std::move(c)) {} + ~OnionSkinRenderData() override = default; + + std::vector canvases; // farthest-to-nearest order + }; + + } // namespace viewport +} // namespace ui +} // namespace xstudio diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.cpp b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.cpp new file mode 100644 index 000000000..932c04be2 --- /dev/null +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.cpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "onion_skin_renderer.hpp" +#include "onion_skin_plugin.hpp" +#include "onion_skin_render_data.hpp" +#include "xstudio/media_reader/image_buffer.hpp" +#include "xstudio/utility/blind_data.hpp" + +using namespace xstudio; +using namespace xstudio::ui::viewport; + + +void OnionSkinRenderer::render_image_overlay( + const Imath::M44f &transform_window_to_viewport_space, + const Imath::M44f &transform_viewport_to_image_space, + const float viewport_du_dpixel, + const float device_pixel_ratio, + const xstudio::media_reader::ImageBufPtr &frame) { + + auto blind = frame.plugin_blind_data(OnionSkinPlugin::PLUGIN_UUID); + const auto *render_data = + dynamic_cast(blind.get()); + if (!render_data || render_data->canvases.empty()) + return; + + if (!canvas_renderer_) + canvas_renderer_ = std::make_unique(); + + const float img_aspect = media_reader::image_aspect(frame); + + // Opacity and tint are already baked into each canvas's items, + // so we just render them directly — no FBO needed. + for (const auto &canvas : render_data->canvases) { + canvas_renderer_->render_canvas( + canvas, + transform_window_to_viewport_space, + transform_viewport_to_image_space, + viewport_du_dpixel, + device_pixel_ratio, + img_aspect, + false); // don't hide strokes + } +} diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.hpp b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.hpp new file mode 100644 index 000000000..408ea6fb3 --- /dev/null +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include "xstudio/plugin_manager/plugin_base.hpp" +#include "xstudio/ui/opengl/opengl_canvas_renderer.hpp" + +namespace xstudio { +namespace ui { + namespace viewport { + + class OnionSkinRenderer : public plugin::ViewportOverlayRenderer { + + public: + OnionSkinRenderer() = default; + + void render_image_overlay( + const Imath::M44f &transform_window_to_viewport_space, + const Imath::M44f &transform_viewport_to_image_space, + const float viewport_du_dpixel, + const float device_pixel_ratio, + const xstudio::media_reader::ImageBufPtr &frame) override; + + float stack_order() const override { return 1.5f; } + + private: + std::unique_ptr canvas_renderer_; + }; + + } // namespace viewport +} // namespace ui +} // namespace xstudio diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/test/CMakeLists.txt b/src/plugin/viewport_overlay/annotation_onion_skin/test/CMakeLists.txt new file mode 100644 index 000000000..e69de29bb