Skip to content

Commit c43d6e3

Browse files
jhery-rdonfelix-rdo
authored andcommitted
Add annotation onion skinning viewport overlay plugin
New self-contained viewport overlay plugin that displays annotations from neighboring frames as semi-transparent, color-tinted overlays on the current frame. Enables reviewers and animators to see annotation progression across frames without scrubbing. Architecture: - HUDPluginBase subclass with configurable frames before/after, opacity falloff, and separate past/future tint colours - Opacity and tint baked directly into copied Canvas items (strokes, captions, shapes), rendered via standard render_canvas() path - Stack order 1.5 (below current annotations at 2.0, above image) - Plugin-local bookmark cache built from frame.bookmarks() during playback — zero core data structure changes - Canvas accessed via public AnnotationBase::user_data() API, no dependency on annotations plugin internals Also adds set_opacity()/set_colour() setters to Stroke, matching the pattern already established by Caption. Signed-off-by: Julien Hery <jhery@rodeofx.com> Co-Authored-By: Nicolas Felix <nfelix@rodeofx.com> Signed-off-by: Ted Waine <ted.waine@gmail.com>
1 parent e96b03e commit c43d6e3

9 files changed

Lines changed: 462 additions & 0 deletions

File tree

include/xstudio/ui/canvas/stroke.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ namespace ui {
7777
bool fade(const float fade_amount);
7878

7979
[[nodiscard]] float opacity() const { return _opacity; }
80+
void set_opacity(const float o) { _opacity = o; }
81+
void set_colour(const utility::ColourTriplet &c) { _colour = c; }
8082
[[nodiscard]] float thickness() const { return _thickness; }
8183
[[nodiscard]] float softness() const { return _softness; }
8284
[[nodiscard]] float size_sensitivity() const { return _size_sensitivity; }

src/plugin/viewport_overlay/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ add_src_and_test(basic_viewport_mask)
22
add_src_and_test(annotations)
33
add_src_and_test(audio_waveform)
44
add_src_and_test(media_metadata_hud)
5+
add_src_and_test(annotation_onion_skin)
56

67
build_studio_plugins("${STUDIO_PLUGINS}")
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
2+
SET(LINK_DEPS
3+
xstudio::module
4+
xstudio::plugin_manager
5+
xstudio::ui::opengl::viewport
6+
Imath::Imath
7+
)
8+
9+
find_package(Imath)
10+
11+
create_plugin_with_alias(
12+
annotation_onion_skin
13+
xstudio::viewport::annotation_onion_skin
14+
${XSTUDIO_GLOBAL_VERSION}
15+
"${LINK_DEPS}")
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
#include "onion_skin_plugin.hpp"
3+
#include "onion_skin_render_data.hpp"
4+
#include "onion_skin_renderer.hpp"
5+
#include "xstudio/media_reader/image_buffer.hpp"
6+
#include "xstudio/bookmark/bookmark.hpp"
7+
#include "xstudio/plugin_manager/plugin_base.hpp"
8+
#include "xstudio/utility/blind_data.hpp"
9+
10+
#include <algorithm>
11+
#include <cmath>
12+
#include <variant>
13+
14+
using namespace xstudio;
15+
using namespace xstudio::ui::viewport;
16+
17+
OnionSkinPlugin::OnionSkinPlugin(
18+
caf::actor_config &cfg, const utility::JsonStore &init_settings)
19+
: plugin::HUDPluginBase(cfg, "Annotation Onion Skin", init_settings, 10.0f) {
20+
21+
frames_before_ = add_integer_attribute("Frames Before", "Before", 3, 0, 20);
22+
add_hud_settings_attribute(frames_before_);
23+
frames_before_->set_tool_tip(
24+
"Maximum frame distance to look back for annotations");
25+
frames_before_->set_redraw_viewport_on_change(true);
26+
27+
frames_after_ = add_integer_attribute("Frames After", "After", 3, 0, 20);
28+
add_hud_settings_attribute(frames_after_);
29+
frames_after_->set_tool_tip(
30+
"Maximum frame distance to look ahead for annotations");
31+
frames_after_->set_redraw_viewport_on_change(true);
32+
33+
base_opacity_ =
34+
add_float_attribute("Base Opacity", "Opacity", 0.4f, 0.05f, 1.0f, 0.05f);
35+
add_hud_settings_attribute(base_opacity_);
36+
base_opacity_->set_tool_tip("Opacity of the nearest neighboring annotation");
37+
base_opacity_->set_redraw_viewport_on_change(true);
38+
39+
opacity_falloff_ =
40+
add_float_attribute("Opacity Falloff", "Falloff", 0.5f, 0.1f, 1.0f, 0.05f);
41+
add_hud_settings_attribute(opacity_falloff_);
42+
opacity_falloff_->set_tool_tip(
43+
"Multiplier applied per frame step further from current frame");
44+
opacity_falloff_->set_redraw_viewport_on_change(true);
45+
46+
use_original_colours_ = add_boolean_attribute(
47+
"Use Original Colours", "Orig Colours", false);
48+
add_hud_settings_attribute(use_original_colours_);
49+
use_original_colours_->set_tool_tip(
50+
"When enabled, keep annotation colours and only reduce opacity. "
51+
"When disabled, tint with Previous/Next colours.");
52+
use_original_colours_->set_redraw_viewport_on_change(true);
53+
54+
past_tint_ = add_colour_attribute(
55+
"Previous Tint", "Prev Tint", utility::ColourTriplet(1.0f, 0.3f, 0.3f));
56+
add_hud_settings_attribute(past_tint_);
57+
past_tint_->set_tool_tip("Tint colour for annotations from previous frames");
58+
past_tint_->set_redraw_viewport_on_change(true);
59+
60+
future_tint_ = add_colour_attribute(
61+
"Next Tint", "Next Tint", utility::ColourTriplet(0.3f, 1.0f, 0.3f));
62+
add_hud_settings_attribute(future_tint_);
63+
future_tint_->set_tool_tip("Tint colour for annotations from future frames");
64+
future_tint_->set_redraw_viewport_on_change(true);
65+
66+
add_hud_description(
67+
"Shows annotations from neighboring frames as semi-transparent, "
68+
"color-tinted overlays on the current frame.");
69+
70+
frames_before_->set_preference_path("/plugin/annotation_onion_skin/frames_before");
71+
frames_after_->set_preference_path("/plugin/annotation_onion_skin/frames_after");
72+
base_opacity_->set_preference_path("/plugin/annotation_onion_skin/base_opacity");
73+
opacity_falloff_->set_preference_path("/plugin/annotation_onion_skin/opacity_falloff");
74+
use_original_colours_->set_preference_path("/plugin/annotation_onion_skin/use_original_colours");
75+
past_tint_->set_preference_path("/plugin/annotation_onion_skin/past_tint");
76+
future_tint_->set_preference_path("/plugin/annotation_onion_skin/future_tint");
77+
}
78+
79+
plugin::ViewportOverlayRendererPtr
80+
OnionSkinPlugin::make_overlay_renderer(const std::string & /*viewport_name*/) {
81+
return plugin::ViewportOverlayRendererPtr(new OnionSkinRenderer());
82+
}
83+
84+
utility::BlindDataObjectPtr OnionSkinPlugin::onscreen_render_data(
85+
const media_reader::ImageBufPtr &image,
86+
const std::string & /*viewport_name*/,
87+
const utility::Uuid & /*playhead_uuid*/,
88+
const bool /*is_hero_image*/,
89+
const bool /*images_are_in_grid_layout*/) const {
90+
91+
if (!visible() || !image)
92+
return {};
93+
94+
const int current_frame = image.playhead_logical_frame();
95+
const int range_before = static_cast<int>(frames_before_->value());
96+
const int range_after = static_cast<int>(frames_after_->value());
97+
const float base_opac = base_opacity_->value();
98+
const float falloff = opacity_falloff_->value();
99+
const bool orig_colours = use_original_colours_->value();
100+
const auto &prev_colour = past_tint_->value();
101+
const auto &next_colour = future_tint_->value();
102+
103+
if (range_before == 0 && range_after == 0)
104+
return {};
105+
106+
// ── Update bookmark cache ──
107+
// image.bookmarks() carries bookmarks covering the current frame.
108+
// We cache them keyed by logical frame to find neighbors later.
109+
//
110+
// Invalidation: when revisiting a frame, if its bookmarks changed
111+
// (different UUIDs or count), we clear the entire cache. This handles
112+
// bookmark deletion, media changes, and bookmark additions.
113+
const auto &frame_bookmarks = image.bookmarks();
114+
{
115+
std::lock_guard<std::mutex> lock(cache_mutex_);
116+
117+
auto it = frame_bookmark_cache_.find(current_frame);
118+
if (it != frame_bookmark_cache_.end()) {
119+
bool changed = (it->second.size() != frame_bookmarks.size());
120+
if (!changed) {
121+
for (size_t i = 0; i < it->second.size(); ++i) {
122+
if (it->second[i]->detail_.uuid_ !=
123+
frame_bookmarks[i]->detail_.uuid_) {
124+
changed = true;
125+
break;
126+
}
127+
}
128+
}
129+
if (changed) {
130+
frame_bookmark_cache_.clear();
131+
}
132+
}
133+
134+
if (!frame_bookmarks.empty()) {
135+
frame_bookmark_cache_[current_frame] = frame_bookmarks;
136+
} else {
137+
frame_bookmark_cache_.erase(current_frame);
138+
}
139+
}
140+
141+
// Collect current frame's annotation pointers — skip these when
142+
// walking neighbors (same annotation spans multiple frames).
143+
std::set<const void *> current_annotations;
144+
for (const auto &bm : frame_bookmarks) {
145+
if (bm && bm->annotation_ && bm->annotation_->user_data())
146+
current_annotations.insert(bm->annotation_->user_data());
147+
}
148+
149+
// ── Helpers ──
150+
auto tint_colour = [](const utility::ColourTriplet &c,
151+
const utility::ColourTriplet &tint) -> utility::ColourTriplet {
152+
return {c.r * tint.r, c.g * tint.g, c.b * tint.b};
153+
};
154+
155+
auto make_canvas_copy = [&](const ui::canvas::Canvas &src, float opacity,
156+
const utility::ColourTriplet &tint,
157+
bool keep_original) -> ui::canvas::Canvas {
158+
ui::canvas::Canvas out(src);
159+
for (auto it = out.begin(); it != out.end(); ++it) {
160+
auto item = *it;
161+
std::visit(
162+
[&](auto &v) {
163+
using T = std::decay_t<decltype(v)>;
164+
if constexpr (std::is_same_v<T, ui::canvas::Stroke>) {
165+
v.set_opacity(v.opacity() * opacity);
166+
if (!keep_original)
167+
v.set_colour(tint_colour(v.colour(), tint));
168+
} else if constexpr (std::is_same_v<T, ui::canvas::Caption>) {
169+
v.set_opacity(v.opacity() * opacity);
170+
v.set_bg_opacity(v.background_opacity() * opacity);
171+
if (!keep_original)
172+
v.set_colour(tint_colour(v.colour(), tint));
173+
} else {
174+
v.opacity *= opacity;
175+
if (!keep_original)
176+
v.colour = tint_colour(v.colour, tint);
177+
}
178+
},
179+
item);
180+
out.overwrite_item(it, item);
181+
}
182+
return out;
183+
};
184+
185+
// Opacity falls off with distance: nearest = base_opac, farther = less.
186+
auto compute_opacity = [&](int distance) -> float {
187+
return base_opac * std::pow(falloff, static_cast<float>(distance - 1));
188+
};
189+
190+
// ── Find neighbor annotations from cache (distance-bounded) ──
191+
struct Candidate {
192+
const ui::canvas::Canvas *canvas;
193+
int abs_distance;
194+
float opacity;
195+
utility::ColourTriplet tint;
196+
};
197+
std::vector<Candidate> candidates;
198+
199+
{
200+
std::lock_guard<std::mutex> lock(cache_mutex_);
201+
202+
// Walk backward — stop when distance exceeds range_before.
203+
if (range_before > 0) {
204+
auto it = frame_bookmark_cache_.lower_bound(current_frame);
205+
if (it != frame_bookmark_cache_.begin()) {
206+
auto pit = it;
207+
while (pit != frame_bookmark_cache_.begin()) {
208+
--pit;
209+
int dist = current_frame - pit->first;
210+
if (dist > range_before)
211+
break;
212+
for (const auto &bm : pit->second) {
213+
if (!bm || !bm->annotation_ || !bm->annotation_->user_data())
214+
continue;
215+
const auto *canvas = static_cast<const ui::canvas::Canvas *>(
216+
bm->annotation_->user_data());
217+
if (!canvas || canvas->empty())
218+
continue;
219+
if (current_annotations.count(canvas))
220+
continue;
221+
candidates.push_back(
222+
{canvas, dist, compute_opacity(dist), prev_colour});
223+
break;
224+
}
225+
}
226+
}
227+
}
228+
229+
// Walk forward — stop when distance exceeds range_after.
230+
if (range_after > 0) {
231+
auto it = frame_bookmark_cache_.upper_bound(current_frame);
232+
while (it != frame_bookmark_cache_.end()) {
233+
int dist = it->first - current_frame;
234+
if (dist > range_after)
235+
break;
236+
for (const auto &bm : it->second) {
237+
if (!bm || !bm->annotation_ || !bm->annotation_->user_data())
238+
continue;
239+
const auto *canvas = static_cast<const ui::canvas::Canvas *>(
240+
bm->annotation_->user_data());
241+
if (!canvas || canvas->empty())
242+
continue;
243+
if (current_annotations.count(canvas))
244+
continue;
245+
candidates.push_back(
246+
{canvas, dist, compute_opacity(dist), next_colour});
247+
break;
248+
}
249+
++it;
250+
}
251+
}
252+
}
253+
254+
if (candidates.empty())
255+
return {};
256+
257+
// Render farthest first so closest onion skin draws on top.
258+
std::sort(candidates.begin(), candidates.end(),
259+
[](const auto &a, const auto &b) { return a.abs_distance > b.abs_distance; });
260+
261+
std::vector<ui::canvas::Canvas> canvases;
262+
canvases.reserve(candidates.size());
263+
for (const auto &c : candidates) {
264+
canvases.push_back(make_canvas_copy(*c.canvas, c.opacity, c.tint, orig_colours));
265+
}
266+
267+
return std::make_shared<OnionSkinRenderData>(std::move(canvases));
268+
}
269+
270+
271+
extern "C" {
272+
plugin_manager::PluginFactoryCollection *plugin_factory_collection_ptr() {
273+
return new plugin_manager::PluginFactoryCollection(
274+
std::vector<std::shared_ptr<plugin_manager::PluginFactory>>(
275+
{std::make_shared<plugin_manager::PluginFactoryTemplate<OnionSkinPlugin>>(
276+
OnionSkinPlugin::PLUGIN_UUID,
277+
"AnnotationOnionSkin",
278+
plugin_manager::PluginFlags::PF_HEAD_UP_DISPLAY |
279+
plugin_manager::PluginFlags::PF_VIEWPORT_OVERLAY,
280+
true,
281+
"RodeoFX",
282+
"Annotation Onion Skinning Overlay")}));
283+
}
284+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
#pragma once
3+
4+
#include <map>
5+
#include <mutex>
6+
#include <set>
7+
8+
#include "xstudio/bookmark/bookmark.hpp"
9+
#include "xstudio/plugin_manager/hud_plugin.hpp"
10+
#include "xstudio/ui/canvas/canvas.hpp"
11+
12+
namespace xstudio {
13+
namespace ui {
14+
namespace viewport {
15+
16+
class OnionSkinRenderer;
17+
18+
class OnionSkinPlugin : public plugin::HUDPluginBase {
19+
public:
20+
inline static const utility::Uuid PLUGIN_UUID{
21+
"b7e3a1c0-5d4f-4e8b-9a2c-1f6d8e0b3c5a"};
22+
23+
OnionSkinPlugin(
24+
caf::actor_config &cfg, const utility::JsonStore &init_settings);
25+
26+
~OnionSkinPlugin() override = default;
27+
28+
protected:
29+
utility::BlindDataObjectPtr onscreen_render_data(
30+
const media_reader::ImageBufPtr &image,
31+
const std::string &viewport_name,
32+
const utility::Uuid &playhead_uuid,
33+
const bool is_hero_image,
34+
const bool images_are_in_grid_layout) const override;
35+
36+
plugin::ViewportOverlayRendererPtr
37+
make_overlay_renderer(const std::string &viewport_name) override;
38+
39+
private:
40+
module::IntegerAttribute *frames_before_;
41+
module::IntegerAttribute *frames_after_;
42+
module::FloatAttribute *base_opacity_;
43+
module::FloatAttribute *opacity_falloff_;
44+
module::BooleanAttribute *use_original_colours_;
45+
module::ColourAttribute *past_tint_;
46+
module::ColourAttribute *future_tint_;
47+
48+
// Bookmark cache: built from image.bookmarks() as user scrubs.
49+
// Invalidated when bookmarks change (detected by comparing
50+
// bookmark UUIDs for revisited frames).
51+
mutable std::mutex cache_mutex_;
52+
mutable std::map<int, bookmark::BookmarkAndAnnotations> frame_bookmark_cache_;
53+
};
54+
55+
} // namespace viewport
56+
} // namespace ui
57+
} // namespace xstudio

0 commit comments

Comments
 (0)