-
Notifications
You must be signed in to change notification settings - Fork 309
/
animate.R
353 lines (335 loc) · 13.9 KB
/
animate.R
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
#' Render a gganim object
#'
#' This function takes a gganim object and renders it into an animation. The
#' nature of the animation is dependent on the renderer, but defaults to using
#' `gifski` to render it to a gif. The length and framerate is decided on render
#' time and can be any two combination of `nframes`, `fps`, and `duration`.
#' Rendering is happening in discrete time units. This means that any event in
#' the animation is rounded of to the nearest frame (e.g. entering will always
#' take a whole number of frames). This means that rounding artifacts are
#' possible when only rendering few frames. To avoid this you can increase the
#' `detail` argument. `detail` will get multiplied to `nframes` and the
#' resulting number of frames will get calculated, but only `nframes` evenly
#' spaced frames are rendered.
#'
#' @param plot,x A `gganim` object
#' @param nframes The number of frames to render (default `100`)
#' @param fps The framerate of the animation in frames/sec (default `10`)
#' @param duration The length of the animation in seconds (unset by default)
#' @param detail The number of additional frames to calculate, per frame (default `1`)
#' @param renderer The function used to render the generated frames into an
#' animation. Gets a vector of paths to images along with the framerate. (by
#' default it will use [gifski_renderer()] if gifski is installed. If not it
#' will use [magick_renderer()] if magick is installed and then [av_renderer()]
#' if av is installed. If all fails it will use the [file_renderer()])
#' @param device The device to use for rendering the single frames. Possible
#' values are `'png'`, `'ragg_png'` (requires the ragg package), `'jpeg'`,
#' `'tiff'`, `'bmp'`, `'svg'`, and `'svglite'` (requires the svglite package).
#' (default `'png'`)
#' @param ref_frame The frame to use for fixing dimensions of the plot, e.g. the
#' space available for axis text. Defaults to the first frame. Negative values
#' counts backwards (-1 is the last frame) (default `1`)
#' @param start_pause,end_pause Number of times to repeat the first and last
#' frame in the animation (default is `0` for both)
#' @param rewind Should the animation roll back in the end (default `FALSE`)
#' @param ... Arguments passed on to the device.
#' For available device arguments, see [grDevices::png()] or [grDevices::svg()]
#'
#' @return The return value of the [renderer][renderers] function
#'
#' @details `print.gganim`() is an alias for `animate()` in the same way as
#' `print.ggplot()` is an alias for `plot.ggplot()`. This ensures that gganimate
#' behaves ggplot2-like and produces the animation when the object is printed.
#' The `plot()` method is different and produces a single frame for inspection
#' (by default frame 50 out of 100).
#'
#' Animations can be saved to disk using [anim_save()] in much the same way
#' [ggsave()][ggplot2::ggsave] works for static plots.
#'
#' @section Defaults:
#' It is possible to overwrite the defaults used by gganimate for the animation
#' by setting them with [options()] (prefixed with `gganimate.`. As an example,
#' if you would like to change the default nframes to 50 you would call
#' `options(gganimate.nframes = 50)`. In order to set default device arguments
#' (those you would normally pass through with `...`) you should use the
#' `gganimate.dev_args` options and provide a list of arguments e.g.
#' `options(gganimate.dev_args = list(width = 800, height = 600))` Defaults set
#' this way can still be overridden by giving arguments directly to `animate()`.
#'
#' **knitr Support:** \cr
#' It is possible to specify the arguments to `animate()` in the chunk options
#' when using `gganimate` with `knitr`. Arguments specified in this way will
#' have precedence over defaults, but not over arguments specified directly in
#' `animate()`. The arguments should be provided as a list to the `gganimate`
#' chunk option, e.g. `{r, gganimate = list(nframes = 50, fps = 20)}`. A few
#' build-in knitr options have relevance for animation and will be used unless
#' given specifically in the `gganimate` list option. The native knitr options
#' supported are:
#'
#' - `dev`: will set `device`
#' - `dev.args`: will set additional arguments to the device (`...`)
#' - `fig.width`, `fig.height`, `fig.asp`, `fig.dim`: will set `width` and
#' `height` of the device.
#'
#' @section Label variables:
#' All plots have a certain set of variables available for string literal
#' interpolation within plot labels. These are:
#'
#' - **frame** gives you the frame index for the current frame
#' - **nframes** gives you the total number of frames in the animation
#' - **progress** gives you the progress of the animation at the current frame
#' (equal to `frame/nframes`)
#' - **data** gives you the layer data for the current frame (as a list of data
#' frames)
#'
#' Further, the transition and view in use can also make variables available.
#' Consult the documentation for these for more detail.
#'
#' @importFrom grid grid.newpage grid.draw convertWidth convertHeight
#' @importFrom grDevices png jpeg tiff bmp svg dev.off
#' @importFrom progress progress_bar
#' @importFrom ggplot2 ggplot_gtable ggplot_build
#' @export
#'
#' @examples
#' anim <- ggplot(mtcars, aes(mpg, disp)) +
#' geom_point(aes(color = gear)) +
#' transition_states(gear, transition_length = 2, state_length = 1) +
#' enter_fade() +
#' exit_fade()
#'
#' \dontrun{
#' # Explicitly animate using default (same as just printing the animation)
#' animate(anim)
#'
#' # Change duration and framerate
#' animate(anim, fps = 20, duration = 15)
#'
#' # Make the animation pause at the end and then rewind
#' animate(anim, nframes = 100, end_pause = 10, rewind = TRUE)
#'
#' # Use a different renderer
#' animate(anim, renderer = file_renderer('~/animation/'))[1:6]
#'
#' # Specify device dimensions and/or resolution
#' animate(anim, height = 2, width = 3, units = "in", res = 150)
#' }
#'
animate <- function(plot, ...) {
UseMethod('animate')
}
#' @export
animate.default <- function(plot, ...) {
stop('animation of ', class(plot)[1], ' objects not supported')
}
#' @rdname animate
#' @export
animate.gganim <- function(plot, nframes, fps, duration, detail, renderer, device, ref_frame, start_pause, end_pause, rewind, ...) {
args <- prepare_args(
nframes = nframes,
fps = fps,
duration = duration,
detail = detail,
renderer = renderer,
device = device,
ref_frame = ref_frame,
start_pause = start_pause,
end_pause = end_pause,
rewind = rewind,
...
)
if (is_knitting() && identical(def_ren$renderer, args$renderer) && !def_ren$has_proper) {
warning('No renderer available. Please install the gifski, av, or magick package to create animated output', call. = FALSE)
return(invisible(NULL))
}
orig_nframes <- args$nframes
args$nframes <- args$nframes - args$start_pause - args$end_pause
if (args$rewind) {
args$nframes <- ceiling((args$nframes - args$start_pause) / 2)
args$end_pause <- ceiling(args$end_pause / 2)
}
nframes_total <- (args$nframes - 1) * args$detail + 1
plot <- prerender(plot, nframes_total)
nframes_final <- get_nframes(plot)
frame_ind <- unique(round(seq(1, nframes_final, length.out = args$nframes)))
if (args$device == 'current') {
frame_ind <- c(rep(frame_ind[1], args$start_pause), frame_ind, rep(frame_ind[length(frame_ind)], args$end_pause))
if (args$rewind) frame_ind <- c(frame_ind, rev(frame_ind))
if (args$ref_frame < 0) {
args$ref_frame <- args$ref_frame - args$end_pause
} else {
args$ref_frame <- args$ref_frame + args$start_pause
}
}
if (args$nframes != length(frame_ind)) {
message('nframes and fps adjusted to match transition')
args$fps <- args$fps * length(frame_ind) / args$nframes
}
if (args$ref_frame < 0) args$ref_frame <- nframes_final + 1 + args$ref_frame
frames_vars <- do.call(
draw_frames,
c(list(plot = plot,
frames = frame_ind,
device = args$device,
ref_frame = args$ref_frame),
args$dev_args)
)
if (args$device == 'current') return(invisible(frames_vars))
if (args$start_pause != 0) frames_vars <- rbind(frames_vars[rep(1, args$start_pause), , drop = FALSE], frames_vars)
if (args$end_pause != 0) frames_vars <- rbind(frames_vars, frames_vars[rep(nrow(frames_vars), args$end_pause), , drop = FALSE])
if (args$rewind) frames_vars <- rbind(frames_vars, frames_vars[rev(seq_len(orig_nframes - nrow(frames_vars))), , drop = FALSE])
animation <- args$renderer(frames_vars$frame_source, args$fps)
attr(animation, 'frame_vars') <- frames_vars
set_last_animation(animation)
animation
}
#' Catch attempt to use the old API
#'
#' @export
#' @keywords internal
gganimate <- function(...) {
stop(
'It appears that you are trying to use the old API, which has been deprecated.\n',
'Please update your code to the new API or install the old version of gganimate\n',
'from https://github.com/thomasp85/gganimate/releases/tag/v0.1.1',
call. = FALSE
)
}
#' @rdname gganimate
#' @export
gg_animate <- gganimate
#' @importFrom utils modifyList
prepare_args <- function(nframes, fps, duration, detail, renderer, device, ref_frame, start_pause, end_pause, rewind, ...) {
args <- list()
chunk_args <- if (is_knitting()) get_knitr_options(knitr::opts_chunk$get(), unlist = FALSE) else list(dev_args = list())
args$nframes <- nframes %?% chunk_args$nframes %||% getOption('gganimate.nframes', 100)
args$fps <- fps %?% chunk_args$fps %||% getOption('gganimate.fps', 10)
duration <- duration %?% chunk_args$duration %||% getOption('gganimate.duration', NULL)
if (!is.null(duration)) {
if (
!missing(fps) ||
is.null(args$nframes) ||
(!is.null(getOption('gganimate.fps')) && is.null(getOption('gganimate.nframes')))
) args$nframes <- duration * args$fps
else args$fps <- args$nframes / duration
}
if (is.null(args$nframes) || is.null(args$fps)) {
stop("At least 2 of 'nframes', 'fps', and 'duration' must be given", call. = FALSE)
}
args$detail <- detail %?% chunk_args$detail %||% getOption('gganimate.detail', 1)
args$renderer <- renderer %?% chunk_args$renderer %||% getOption('gganimate.renderer', def_ren$renderer)
args$device <- tolower(device %?% chunk_args$device %||% getOption('gganimate.device', 'png'))
if (args$device == 'svglite' && !requireNamespace('svglite', quietly = TRUE)) {
stop('The svglite package is required to use this device', call. = FALSE)
}
if (args$device == 'ragg_png' && !requireNamespace('ragg', quietly = TRUE)) {
stop('The ragg package is required to use this device', call. = FALSE)
}
args$ref_frame <- ref_frame %?% chunk_args$ref_frame %||% getOption('gganimate.ref_frame', 1)
args$start_pause <- start_pause %?% chunk_args$start_pause %||% getOption('gganimate.start_pause', 0)
args$end_pause <- end_pause %?% chunk_args$end_pause %||% getOption('gganimate.end_pause', 0)
args$rewind <- rewind %?% chunk_args$rewind %||% getOption('gganimate.rewind', FALSE)
dev_args <- list(...)
args$dev_args <- if (length(dev_args) > 0) {
modifyList(getOption('gganimate.dev_args', list()), dev_args)
} else {
modifyList(getOption('gganimate.dev_args', list()), chunk_args$dev_args)
}
args
}
# Build plot for a specific number of frames
prerender <- function(plot, nframes) {
plot <- set_nframes(plot, nframes)
ggplot_build(plot)
}
# Draw each frame as an image based on a specified device
# Returns a data.frame of frame metadata with image location in frame_source
# column
draw_frames <- function(plot, frames, device, ref_frame, ...) {
stream <- device == 'current'
dims <- tryCatch(
plot_dims(plot, ref_frame),
error = function(e) {
warning('Cannot get dimensions of plot table. Plot region might not be fixed', call. = FALSE)
list(widths = NULL, heights = NULL)
}
)
dir <- tempfile(pattern = '')
dir.create(dir, showWarnings = FALSE)
files <- file.path(dir, sprintf('gganim_plot%04d', seq_along(frames)))
files <- switch(
tolower(device),
ragg_png = ,
png = paste0(files, '.png'),
jpg = ,
jpeg = paste0(files, '.jpg'),
tif = ,
tiff = paste0(files, '.tif'),
bmp = paste0(files, '.bmp'),
svglite = ,
svg = paste0(files, '.svg'),
current = files,
stop('Unsupported device', call. = FALSE)
)
device <- switch(
device,
ragg_png = ragg::agg_png,
png = png,
jpg = ,
jpeg = jpeg,
tif = ,
tiff = tiff,
bmp = bmp,
svg = svg,
svglite = svglite::svglite
)
pb <- progress_bar$new(
'Rendering [:bar] at :fps fps ~ eta: :eta',
total = length(frames)
)
start <- Sys.time()
pb$tick(0)
for (i in seq_along(frames)) {
if (!stream) device(files[i], ...)
tryCatch(
plot$scene$plot_frame(plot, frames[i], widths = dims$widths, heights = dims$heights),
error = function(e) {
warning(conditionMessage(e), call. = FALSE)
}
)
rate <- i/as.double(Sys.time() - start, units = 'secs')
if (is.nan(rate)) rate <- 0
rate <- format(rate, digits = 2)
pb$tick(tokens = list(fps = rate))
if (!stream) dev.off()
}
frame_vars <- plot$scene$frame_vars[frames, , drop = FALSE]
if (!stream) frame_vars$frame_source <- files
frame_vars
}
# Get dimensions of plot based on a reference frame
plot_dims <- function(plot, ref_frame) {
tmpf <- tempfile()
png(tmpf)
on.exit({
dev.off()
unlink(tmpf)
})
frame <- plot$scene$get_frame(plot, ref_frame)
frame <- ggplot_gtable(frame)
widths_rel <- frame$widths
widths <- convertWidth(widths_rel, 'mm')
heights_rel <- frame$heights
heights <- convertHeight(heights_rel, 'mm')
if (is.list(widths)) { # New unit spec
null_widths <- vapply(unclass(widths), `[[`, numeric(1), 1L) == 0
null_heights <- vapply(unclass(heights), `[[`, numeric(1), 1L) == 0
} else {
null_widths <- as.numeric(widths) == 0
null_heights <- as.numeric(heights) == 0
}
widths[null_widths] <- widths_rel[null_widths]
heights[null_heights] <- heights_rel[null_heights]
list(widths = widths, heights = heights)
}
is_knitting <- function() isTRUE(getOption("knitr.in.progress"))