diff --git a/CHANGELOG.md b/CHANGELOG.md
index cec5a5e93ac..c5650080f23 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## [Unreleased]
- Added `origin` to transformation so that the reference point of `rotate!()` and `scale!()` can be modified [#4472](https://github.com/MakieOrg/Makie.jl/pull/4472)
+- Improved performance of `record` by avoiding unnecessary copying in common cases [#4475](https://github.com/MakieOrg/Makie.jl/pull/4475).
## [0.21.14] - 2024-10-11
diff --git a/assets/cursor.png b/assets/cursor.png
new file mode 100644
index 00000000000..e630c7c272f
Binary files /dev/null and b/assets/cursor.png differ
diff --git a/assets/cursor_pressed.png b/assets/cursor_pressed.png
new file mode 100644
index 00000000000..b8ea70dbead
Binary files /dev/null and b/assets/cursor_pressed.png differ
diff --git a/docs/fake_interaction.jl b/docs/fake_interaction.jl
new file mode 100644
index 00000000000..7fa78be5aa5
--- /dev/null
+++ b/docs/fake_interaction.jl
@@ -0,0 +1,254 @@
+module FakeInteraction
+
+using Makie
+using GLMakie
+using Makie.Animations
+
+export interaction_record
+export MouseTo
+export LeftClick
+export LeftDown
+export LeftUp
+export Lazy
+export Wait
+export relative_pos
+
+@recipe(Cursor) do scene
+ Theme(
+ color = :black,
+ strokecolor = :white,
+ strokewidth = 1,
+ width = 10,
+ notch = 2,
+ shaftwidth = 2.5,
+ shaftlength = 4,
+ headlength = 12,
+ multiplier = 1,
+ )
+end
+
+function Makie.plot!(p::Cursor)
+ poly = lift(p.width, p.notch, p.shaftwidth, p.shaftlength, p.headlength) do w, draw, wshaft, lshaft, lhead
+ ps = Point2f[
+ (0, 0),
+ (-w/2, -lhead),
+ (-wshaft/2, -lhead+draw),
+ (-wshaft/2, -lhead-lshaft),
+ (wshaft/2, -lhead-lshaft),
+ (wshaft/2, -lhead+draw),
+ (w/2, -lhead),
+ ]
+
+ angle = asin((-w/2) / (-lhead))
+
+ Makie.Polygon(map(ps) do point
+ Makie.Mat2f(cos(angle), sin(angle), -sin(angle), cos(angle)) * point
+ end)
+ end
+
+ scatter!(p, p[1], marker = poly, markersize = p.multiplier, color = p.color, strokecolor = p.strokecolor, strokewidth = p.strokewidth,
+ glowcolor = (:black, 0.10), glowwidth = 2, transform_marker = true)
+
+ return p
+end
+
+struct Lazy
+ f::Function
+end
+
+struct MouseTo{T}
+ target::T
+ duration::Union{Nothing,Float64}
+end
+
+MouseTo(target) = MouseTo(target, nothing)
+
+function mousepositions_frame(m::MouseTo, startpos, t)
+
+ dur = duration(m, startpos)
+
+ keyframe_from = Animations.Keyframe(0.0, Point2f(startpos))
+ keyframe_to = Animations.Keyframe(dur, Point2f(m.target))
+
+ pos = Animations.interpolate(saccadic(2), t, keyframe_from, keyframe_to)
+ [pos]
+end
+function mousepositions_end(m::MouseTo, startpos)
+ [m.target]
+end
+
+
+duration(mouseto::MouseTo, prev_position) = mouseto.duration === nothing ? automatic_duration(mouseto, prev_position) : mouseto.duration
+function automatic_duration(mouseto::MouseTo, prev_position)
+ dist = sqrt(+(((mouseto.target .- prev_position) .^ 2)...))
+ 0.6 + dist / 1000 * 0.5
+end
+
+struct Wait
+ duration::Float64
+end
+
+duration(w::Wait, prev_position) = w.duration
+
+struct LeftClick end
+
+duration(::LeftClick, _) = 0.15
+mouseevents_start(l::LeftClick) = [Makie.MouseButtonEvent(Mouse.left, Mouse.press)]
+mouseevents_end(l::LeftClick) = [Makie.MouseButtonEvent(Mouse.left, Mouse.release)]
+
+struct LeftDown end
+
+duration(::LeftDown, _) = 0.0
+mouseevents_start(l::LeftDown) = [Makie.MouseButtonEvent(Mouse.left, Mouse.press)]
+
+struct LeftUp end
+
+duration(::LeftUp, _) = 0.0
+mouseevents_start(l::LeftUp) = [Makie.MouseButtonEvent(Mouse.left, Mouse.release)]
+
+mouseevents_start(obj) = []
+mouseevents_end(obj) = []
+mouseevents_frame(obj, t) = []
+mousepositions_start(obj, startpos) = []
+mousepositions_end(obj, startpos) = []
+mousepositions_frame(obj, startpos, t) = []
+
+function alpha_blend(fg::Makie.RGBA, bg::Makie.RGB)
+ r = (fg.r * fg.alpha + bg.r * (1 - fg.alpha))
+ g = (fg.g * fg.alpha + bg.g * (1 - fg.alpha))
+ b = (fg.b * fg.alpha + bg.b * (1 - fg.alpha))
+ return RGBf(r, g, b)
+end
+
+
+function recordframe_with_cursor_overlay!(io, cursor_pos, viewport, cursor_img, cursor_tip_frac)
+ glnative = Makie.colorbuffer(io.screen, Makie.GLNative)
+ # Make no copy if already Matrix{RGB{N0f8}}
+ # There may be a 1px padding for odd dimensions
+ xdim, ydim = size(glnative)
+ copy!(view(io.buffer, 1:xdim, 1:ydim), glnative)
+
+ render_cursor!(io.buffer, (xdim, ydim), cursor_pos, viewport, cursor_img, cursor_tip_frac)
+
+ write(io.io, io.buffer)
+ return
+end
+
+function render_cursor!(buffer, sz, cursor_pos, viewport, cursor_img, cursor_tip_frac)
+ cursor_loc_idx = round.(Int, cursor_pos ./ viewport.widths .* sz) .- round.(Int, (1, -1) .* (cursor_tip_frac .* size(cursor_img)))
+ for idx in CartesianIndices(cursor_img)
+ image_idx = Tuple(idx) .* (1, -1) .+ cursor_loc_idx
+ if all((1, 1) .<= image_idx .<= sz)
+ px = buffer[image_idx...]
+ cursor_px = cursor_img[idx]
+ buffer[image_idx...] = alpha_blend(cursor_px, px)
+ end
+ end
+ return
+end
+
+function interaction_record(func, figlike, filepath, events::AbstractVector; fps = 60, px_per_unit = 2, update = true, kwargs...)
+ content_scene = Makie.get_scene(figlike)
+ sz = content_scene.viewport[].widths
+ # composite_scene = Scene(; camera = campixel!, size = sz)
+ # scr = display(GLMakie.Screen(), composite_scene)
+ # img = Observable(zeros(RGBAf, sz...))
+ # image!(composite_scene, 0..sz[1], 0..sz[2], img)
+ cursor_position = Observable(sz ./ 2)
+ content_scene.events.mouseposition[] = tuple(cursor_position[]...)
+ # curs = cursor!(composite_scene, cursor_position)
+ if update
+ Makie.update_state_before_display!(figlike)
+ end
+
+ if isempty(events)
+ error("Event list is empty")
+ end
+
+ cursor_img = Makie.FileIO.load(joinpath(@__DIR__, "..", "assets", "cursor.png"))'
+ cursor_pressed_img = Makie.FileIO.load(joinpath(@__DIR__, "..", "assets", "cursor_pressed.png"))'
+ cursor_tip_frac = (0.3, 0.15)
+
+ record(content_scene, filepath; framerate = fps, px_per_unit, kwargs...) do io
+ t = 0.0
+ t_event = 0.0
+ current_duration = 0.0
+
+ i_event = 1
+ i_frame = 1
+ event_startposition = Point2f(content_scene.events.mouseposition[])
+
+ while i_event <= length(events)
+ event = events[i_event]
+ if event isa Lazy
+ event = event.f(figlike)
+ end
+
+ t_event += current_duration # from previous
+ event_startposition = Point2f(content_scene.events.mouseposition[])
+ current_duration = duration(event, event_startposition)
+
+ mouseevents = mouseevents_start(event)
+ for mouseevent in mouseevents
+ content_scene.events.mousebutton[] = mouseevent
+ end
+ mousepositions = mousepositions_start(event, event_startposition)
+ for mouseposition in mousepositions
+ content_scene.events.mouseposition[] = tuple(mouseposition...)
+ cursor_position[] = mouseposition
+ end
+
+ while t < t_event + current_duration
+ mouseevents = mouseevents_frame(event, t - t_event)
+ for mouseevent in mouseevents
+ content_scene.events.mousebutton[] = mouseevent
+ end
+ mousepositions = mousepositions_frame(event, event_startposition, t - t_event)
+ for mouseposition in mousepositions
+ content_scene.events.mouseposition[] = tuple(mouseposition...)
+ cursor_position[] = mouseposition
+ end
+
+ func(i_frame, t)
+ # img[] = rotr90(Makie.colorbuffer(figlike, update = false))
+ # if content_scene.events.mousebutton[].action === Makie.Mouse.press
+ # curs.multiplier = 0.8
+ # else
+ # curs.multiplier = 1.0
+ # end
+
+ mouse_pressed = content_scene.events.mousebutton[].action === Makie.Mouse.press
+
+ recordframe_with_cursor_overlay!(
+ io,
+ content_scene.events.mouseposition[],
+ content_scene.viewport[],
+ mouse_pressed ? cursor_pressed_img : cursor_img,
+ cursor_tip_frac
+ )
+ i_frame += 1
+ t = i_frame / fps
+ end
+
+ mouseevents = mouseevents_end(event)
+ for mouseevent in mouseevents
+ content_scene.events.mousebutton[] = mouseevent
+ end
+ mousepositions = mousepositions_end(event, event_startposition)
+ for mouseposition in mousepositions
+ content_scene.events.mouseposition[] = tuple(mouseposition...)
+ cursor_position[] = mouseposition
+ end
+
+ i_event += 1
+ end
+ return
+ end
+ return
+end
+
+interaction_record(figlike, filepath, events::AbstractVector; kwargs...) = interaction_record((args...,) -> nothing, figlike, filepath, events; kwargs...)
+
+relative_pos(block, rel) = Point2f(block.layoutobservables.computedbbox[].origin .+ rel .* block.layoutobservables.computedbbox[].widths)
+
+end
\ No newline at end of file
diff --git a/docs/makedocs.jl b/docs/makedocs.jl
index 8326548d3fe..4b3074df84a 100644
--- a/docs/makedocs.jl
+++ b/docs/makedocs.jl
@@ -31,6 +31,7 @@ end
include("figure_block.jl")
include("attrdocs_block.jl")
include("shortdocs_block.jl")
+include("fake_interaction.jl")
docs_url = "docs.makie.org"
repo = "github.com/MakieOrg/Makie.jl.git"
diff --git a/docs/src/assets/beeswarm_example.png b/docs/src/assets/beeswarm_example.png
new file mode 100644
index 00000000000..98d10bbec0c
Binary files /dev/null and b/docs/src/assets/beeswarm_example.png differ
diff --git a/docs/src/assets/geomakie_example.png b/docs/src/assets/geomakie_example.png
index 1cb015d6dc8..670c2175845 100644
Binary files a/docs/src/assets/geomakie_example.png and b/docs/src/assets/geomakie_example.png differ
diff --git a/docs/src/ecosystem.md b/docs/src/ecosystem.md
index c4fc9b902d9..a064ca7e7aa 100644
--- a/docs/src/ecosystem.md
+++ b/docs/src/ecosystem.md
@@ -2,7 +2,7 @@
These packages and sites are maintained by third parties. If you install packages, keep an eye on version conflicts or downgrades as the Makie ecosystem is developing quickly so things break occasionally.
-## AlgebraOfGraphics.jl
+## [AlgebraOfGraphics.jl](https://github.com/MakieOrg/AlgebraOfGraphics.jl)
Grammar-of-graphics style plotting, inspired by ggplot2.
@@ -10,7 +10,7 @@ Grammar-of-graphics style plotting, inspired by ggplot2.
```
-## Beautiful Makie
+## [Beautiful Makie](https://beautiful.makie.org/dev/)
This third-party gallery contains many advanced examples.
@@ -18,7 +18,7 @@ This third-party gallery contains many advanced examples.
```
-## GraphMakie.jl
+## [GraphMakie.jl](https://github.com/MakieOrg/GraphMakie.jl)
Graphs with two- and three-dimensional layout algorithms.
@@ -26,10 +26,18 @@ Graphs with two- and three-dimensional layout algorithms.
```
-## GeoMakie.jl
+## [GeoMakie.jl](https://github.com/MakieOrg/GeoMakie.jl)
Geographic plotting utilities including projections.
```@raw html
-```
\ No newline at end of file
+```
+
+## [SwarmMakie.jl](https://github.com/MakieOrg/SwarmMakie.jl)
+
+Beeswarm plots for Makie.jl!
+
+```@raw html
+
+```
diff --git a/docs/src/reference/blocks/button.md b/docs/src/reference/blocks/button.md
index 5d32181e156..95ef7caae07 100644
--- a/docs/src/reference/blocks/button.md
+++ b/docs/src/reference/blocks/button.md
@@ -1,6 +1,8 @@
# Button
-```@figure backend=GLMakie
+```@example button
+using GLMakie
+GLMakie.activate!() # hide
fig = Figure()
@@ -24,8 +26,41 @@ barplot!(counts, color = cgrad(:Spectral)[LinRange(0, 1, 5)])
ylims!(ax, 0, 20)
fig
+nothing # hide
```
+```@setup button
+using ..FakeInteraction
+
+events = [
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(buttons[1], (0.3, 0.3)))
+ end,
+ LeftClick(),
+ Wait(0.2),
+ LeftClick(),
+ Wait(0.2),
+ LeftClick(),
+ Wait(0.4),
+ Lazy() do fig
+ MouseTo(relative_pos(buttons[4], (0.7, 0.2)))
+ end,
+ Wait(0.2),
+ LeftClick(),
+ Wait(0.2),
+ LeftClick(),
+ Wait(0.2),
+ LeftClick(),
+ Wait(0.5)
+]
+
+interaction_record(fig, "button_example.mp4", events)
+```
+
+```@raw html
+
+```
## Attributes
```@attrdocs
diff --git a/docs/src/reference/blocks/intervalslider.md b/docs/src/reference/blocks/intervalslider.md
index 23baf500607..c6bcee7b8cb 100644
--- a/docs/src/reference/blocks/intervalslider.md
+++ b/docs/src/reference/blocks/intervalslider.md
@@ -18,7 +18,9 @@ If `startvalues === Makie.automatic`, the full interval will be selected (this i
If you set the attribute `snap = false`, the slider will move continously while dragging and only jump to the closest available values when releasing the mouse.
-```@figure
+```@example intervalslider
+using GLMakie
+GLMakie.activate!() # hide
f = Figure()
Axis(f[1, 1], limits = (0, 1, 0, 1))
@@ -50,6 +52,64 @@ end
scatter!(points, color = colors, colormap = [:gray90, :dodgerblue], strokewidth = 0)
f
+nothing # hide
+```
+
+```@setup intervalslider
+using ..FakeInteraction
+
+events = [
+ Wait(1),
+ Lazy() do fig
+ MouseTo(relative_pos(rs_h, (0.2, 0.5)))
+ end,
+ Wait(0.2),
+ LeftDown(),
+ Wait(0.3),
+ Lazy() do fig
+ MouseTo(relative_pos(rs_h, (0.5, 0.6)))
+ end,
+ Wait(0.2),
+ LeftUp(),
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(rs_h, (0.625, 0.4)))
+ end,
+ Wait(0.2),
+ LeftDown(),
+ Wait(0.3),
+ Lazy() do fig
+ MouseTo(relative_pos(rs_h, (0.375, 0.5)))
+ end,
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(rs_h, (0.8, 0.5)))
+ end,
+ LeftUp(),
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(rs_v, (0.5, 0.66)))
+ end,
+ Wait(0.3),
+ LeftDown(),
+ Lazy() do fig
+ MouseTo(relative_pos(rs_v, (0.5, 0.33)))
+ end,
+ LeftUp(),
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(rs_v, (0.5, 0.8)))
+ end,
+ Wait(0.3),
+ LeftClick(),
+ Wait(2),
+]
+
+interaction_record(f, "intervalslider_example.mp4", events)
+```
+
+```@raw html
+
```
## Attributes
diff --git a/docs/src/reference/blocks/menu.md b/docs/src/reference/blocks/menu.md
index df523623311..228f53b92b8 100644
--- a/docs/src/reference/blocks/menu.md
+++ b/docs/src/reference/blocks/menu.md
@@ -1,6 +1,9 @@
# Menu
-```@figure backend=GLMakie
+```@example menu
+using GLMakie
+GLMakie.activate!() # hide
+
fig = Figure()
menu = Menu(fig, options = ["viridis", "heat", "blues"], default = "blues")
@@ -40,9 +43,45 @@ on(menu2.selection) do s
end
notify(menu2.selection)
-menu2.is_open = true
-
fig
+nothing # hide
+```
+
+```@setup menu
+using ..FakeInteraction
+
+events = [
+ Wait(1),
+ Lazy() do fig
+ MouseTo(relative_pos(menu, (0.3, 0.3)))
+ end,
+ LeftClick(),
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(menu, (0.33, -0.6)))
+ end,
+ Wait(0.2),
+ LeftClick(),
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(menu2, (0.28, 0.3)))
+ end,
+ Wait(0.2),
+ LeftClick(),
+ Wait(0.2),
+ Lazy() do fig
+ MouseTo(relative_pos(menu2, (0.4, -3.6)))
+ end,
+ Wait(0.2),
+ LeftClick(),
+ Wait(2),
+]
+
+interaction_record(fig, "menu_example.mp4", events)
+```
+
+```@raw html
+
```
diff --git a/docs/src/reference/blocks/slidergrid.md b/docs/src/reference/blocks/slidergrid.md
index cb663ed3889..e6df367df56 100644
--- a/docs/src/reference/blocks/slidergrid.md
+++ b/docs/src/reference/blocks/slidergrid.md
@@ -4,7 +4,10 @@ The column with the value labels is automatically set to a fixed width, so that
This width is chosen by setting each slider to a few values and recording the maximum label width.
Alternatively, you can set the width manually with attribute `value_column_width`.
-```@figure backend=GLMakie
+```@example slidergrid
+using GLMakie
+GLMakie.activate!() # hide
+
fig = Figure()
@@ -27,6 +30,45 @@ barplot!(ax, bars, color = [:yellow, :orange, :red])
ylims!(ax, 0, 30)
fig
+nothing # hide
+```
+
+```@setup slidergrid
+using ..FakeInteraction
+
+events = [
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(sg.sliders[1], (0.1, 0.5)))
+ end,
+ LeftDown(),
+ Wait(0.2),
+ Lazy() do fig
+ MouseTo(relative_pos(sg.sliders[1], (0.8, 0.5)))
+ end,
+ Wait(0.2),
+ LeftUp(),
+ Wait(0.5),
+ Lazy() do fig
+ MouseTo(relative_pos(sg.sliders[3], (0.5, 0.5)))
+ end,
+ LeftDown(),
+ Wait(0.3),
+ Lazy() do fig
+ MouseTo(relative_pos(sg.sliders[3], (1, 0.6)))
+ end,
+ Wait(0.3),
+ Lazy() do fig
+ MouseTo(relative_pos(sg.sliders[3], (0.1, 0.3)))
+ end,
+ Wait(0.5),
+]
+
+interaction_record(fig, "slidergrid_example.mp4", events)
+```
+
+```@raw html
+
```
## Attributes
diff --git a/src/ffmpeg-util.jl b/src/ffmpeg-util.jl
index b7e7b1bb55c..2bb14ffad61 100644
--- a/src/ffmpeg-util.jl
+++ b/src/ffmpeg-util.jl
@@ -286,8 +286,12 @@ function recordframe!(io::VideoStream)
# Make no copy if already Matrix{RGB{N0f8}}
# There may be a 1px padding for odd dimensions
xdim, ydim = size(glnative)
- copy!(view(io.buffer, 1:xdim, 1:ydim), glnative)
- write(io.io, io.buffer)
+ if eltype(glnative) == eltype(io.buffer) && size(glnative) == size(io.buffer)
+ write(io.io, glnative)
+ else
+ copy!(view(io.buffer, 1:xdim, 1:ydim), glnative)
+ write(io.io, io.buffer)
+ end
next_tick!(io.tick_controller)
return
end