diff --git a/DESCRIPTION b/DESCRIPTION index f398fd9..891700c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -51,7 +51,7 @@ VignetteBuilder: knitr Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.2 +RoxygenNote: 7.3.3 Collate: 'aaa.R' 'anim_save.R' diff --git a/NAMESPACE b/NAMESPACE index c6cd857..8b278f5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -57,6 +57,7 @@ export(ViewZoom) export(ViewZoomManual) export(anim_save) export(animate) +export(animated_renderer) export(av_renderer) export(ease_aes) export(enter_appear) @@ -124,6 +125,7 @@ importFrom(ggplot2,CoordCartesian) importFrom(ggplot2,FacetNull) importFrom(ggplot2,Layout) importFrom(ggplot2,geom_blank) +importFrom(ggplot2,ggplot) importFrom(ggplot2,ggplot_add) importFrom(ggplot2,ggplot_build) importFrom(ggplot2,ggplot_gtable) @@ -137,6 +139,8 @@ importFrom(glue,glue) importFrom(glue,glue_data) importFrom(grDevices,bmp) importFrom(grDevices,col2rgb) +importFrom(grDevices,dev.cur) +importFrom(grDevices,dev.flush) importFrom(grDevices,dev.off) importFrom(grDevices,jpeg) importFrom(grDevices,png) diff --git a/NEWS.md b/NEWS.md index f9f8a0a..e2f5eba 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,10 @@ # gganimate (development version) +* Add support for animated devices that can handle multiple frames in one session. + You can now pass device functions directly to the `device` parameter (e.g., + `device = agg_webp_anim`) and specify the output file extension with the + `extension` parameter. Use `animated_renderer()` to handle the output. + # gganimate 1.0.11 * Fix an issue with label rendering in ggplot2 v4 diff --git a/R/animate.R b/R/animate.R index 3b1a36d..776fb69 100644 --- a/R/animate.R +++ b/R/animate.R @@ -22,18 +22,17 @@ #' 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 device Device for rendering frames. Use string names for file-based devices +#' ('png', 'ragg_png', 'jpeg', 'tiff', 'bmp', 'svg', 'svglite', 'current') or +#' pass device functions directly for animated devices (e.g., `agg_webp_anim`). #' @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()] +#' @param ... Arguments passed to the device. For animated devices, include +#' `extension` for file extension (e.g., `".webp"`). #' #' @return The return value of the [renderer][renderers] function #' @@ -86,9 +85,9 @@ #' 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 grDevices png jpeg tiff bmp svg dev.off dev.cur dev.flush #' @importFrom progress progress_bar -#' @importFrom ggplot2 ggplot_gtable ggplot_build +#' @importFrom ggplot2 ggplot_gtable ggplot_build ggplot labs #' @export #' #' @examples @@ -154,7 +153,7 @@ animate.gganim <- function(plot, nframes, fps, duration, detail, renderer, devic frame_ind <- unique0(round(seq(1, nframes_final, length.out = args$nframes))) - if (args$device == 'current') { + if (is.character(args$device) && 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) { @@ -171,15 +170,16 @@ animate.gganim <- function(plot, nframes, fps, duration, detail, renderer, devic if (args$ref_frame < 0) args$ref_frame <- nframes_final + 1 + args$ref_frame - frames_vars <- inject(draw_frames(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 <- vec_rbind0(frames_vars[rep(1, args$start_pause), , drop = FALSE], frames_vars) - if (args$end_pause != 0) frames_vars <- vec_rbind0(frames_vars, frames_vars[rep(nrow(frames_vars), args$end_pause), , drop = FALSE]) - if (args$rewind) frames_vars <- vec_rbind0(frames_vars, frames_vars[rev(seq_len(orig_nframes - nrow(frames_vars))), , drop = FALSE]) + if (is.function(args$device)) { + animation <- render_with_animated_device(plot, frame_ind, args) + frame_data <- attr(animation, "frame_data") + } else { + frame_data <- inject(draw_frames(plot = plot, frames = frame_ind, device = args$device, ref_frame = args$ref_frame, !!!args$dev_args)) + if (is.character(args$device) && args$device == 'current') return(invisible(frame_data)) - animation <- args$renderer(frames_vars$frame_source, args$fps) - attr(animation, 'frame_vars') <- frames_vars + animation <- render_with_file_device(frame_data, args, orig_nframes) + } + attr(animation, "frame_vars") <- frame_data set_last_animation(animation) animation } @@ -218,13 +218,18 @@ prepare_args <- function(nframes, fps, duration, detail, renderer, device, ref_f } 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') { - check_installed('svglite', 'to use the svglite device') - } - if (args$device == 'ragg_png') { - check_installed('ragg', 'to use the ragg_png device') + device_value <- device %?% chunk_args$device %||% getOption('gganimate.device', 'png') + args$device <- if (is.function(device_value)) device_value else tolower(device_value) + + if (!is.function(args$device)) { + if (args$device == 'svglite') { + check_installed('svglite', 'to use the svglite device') + } + if (args$device == 'ragg_png') { + check_installed('ragg', 'to use the ragg_png device') + } } + 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) @@ -244,6 +249,10 @@ prerender <- function(plot, nframes) { # Returns a data.frame of frame metadata with image location in frame_source # column draw_frames <- function(plot, frames, device, ref_frame, ...) { + if (is.function(device)) { + return(draw_frames_animated(plot, frames, device, ref_frame, ...)) + } + stream <- device == 'current' dims <- try_fetch( @@ -311,14 +320,93 @@ draw_frames <- function(plot, frames, device, ref_frame, ...) { if (!stream) dev.off() } - frame_vars <- plot$scene$frame_vars[frames, , drop = FALSE] - if (!stream) frame_vars$frame_source <- files - frame_vars + frame_data <- plot$scene$frame_vars[frames, , drop = FALSE] + if (!stream) frame_data$frame_source <- files + frame_data } + +draw_frames_animated <- function(plot, frames, device_fn, ref_frame, ...) { + if (!is.function(device_fn)) { + cli::cli_abort("Animated device must be a function") + } + + args <- list(...) + extension <- args$extension %||% ".out" + output_file <- tempfile(fileext = extension) + + device_params <- names(formals(device_fn)) + device_args <- args[names(args) %in% device_params] + device_args$filename <- output_file + + try_fetch( + do.call(device_fn, device_args), + error = function(e) { + cli::cli_abort("Failed to initialize animated device", parent = e) + } + ) + + on.exit({ + if (dev.cur() != 1) dev.off() + }) + + dims <- try_fetch( + plot_dims(plot, ref_frame), + error = function(e) { + cli::cli_warn( + "Cannot get dimensions of plot table. Plot region might not be fixed", + parent = e + ) + list(widths = NULL, heights = NULL) + } + ) + + pb <- progress_bar$new( + "Rendering animated [:bar] at :fps fps ~ eta: :eta", + total = length(frames) + ) + start <- Sys.time() + pb$tick(0) + + for (i in seq_along(frames)) { + grid.newpage() + try_fetch( + plot$scene$plot_frame( + plot, frames[i], + newpage = FALSE, + widths = dims$widths, + heights = dims$heights + ), + error = function(e) { + cli::cli_warn("Failed to plot frame", parent = e) + } + ) + dev.flush() + + 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)) + } + + frame_data <- plot$scene$frame_vars[frames, , drop = FALSE] + frame_data$frame_source <- rep(output_file, length(frames)) + + dev.off() + on.exit(NULL) + + frame_data +} + + # Get dimensions of plot based on a reference frame plot_dims <- function(plot, ref_frame) { tmpf <- tempfile() - png(tmpf) + try_fetch( + png(tmpf), + error = function(e) { + cli::cli_abort("Failed to create temporary graphics device", parent = e) + } + ) on.exit({ dev.off() unlink(tmpf) @@ -341,6 +429,21 @@ plot_dims <- function(plot, ref_frame) { list(widths = widths, heights = heights) } +render_with_animated_device <- function(plot, frame_ind, args) { + frame_data <- inject(draw_frames_animated(plot = plot, frames = frame_ind, device_fn = args$device, ref_frame = args$ref_frame, !!!args$dev_args)) + animation <- unique(frame_data$frame_source)[1] + attr(animation, "frame_data") <- frame_data + animation +} + +render_with_file_device <- function(frame_data, args, orig_nframes) { + if (args$start_pause != 0) frame_data <- vec_rbind0(frame_data[rep(1, args$start_pause), , drop = FALSE], frame_data) + if (args$end_pause != 0) frame_data <- vec_rbind0(frame_data, frame_data[rep(nrow(frame_data), args$end_pause), , drop = FALSE]) + if (args$rewind) frame_data <- vec_rbind0(frame_data, frame_data[rev(seq_len(orig_nframes - nrow(frame_data))), , drop = FALSE]) + + args$renderer(frame_data$frame_source, args$fps) +} + is_knitting <- function() isTRUE(getOption("knitr.in.progress")) render_frame <- function(frame) { diff --git a/R/renderers.R b/R/renderers.R index d880768..2698c29 100644 --- a/R/renderers.R +++ b/R/renderers.R @@ -63,6 +63,7 @@ #' - **`av_renderer`**: Returns a [video_file] object #' - **`ffmpeg_renderer`**: Returns a [video_file] object #' - **`file_renderer`**: Returns a vector of file paths +#' - **`animated_renderer`**: Returns a file path to the animated output #' #' @name renderers #' @rdname renderers @@ -218,6 +219,20 @@ sprite_renderer <- function() { sprite_file(file, fps, width = single_dim$width, full_width = full_dim$width, height = single_dim$height) } } +#' @rdname renderers +#' @export +animated_renderer <- function() { + function(frames, fps) { + # For animated devices, all frame paths are the same file + output_file <- unique(frames)[1] + + if (!file.exists(output_file)) { + cli::cli_abort("Animated output file not found: {output_file}") + } + + output_file + } +} # HELPERS ----------------------------------------------------------------- diff --git a/man/animate.Rd b/man/animate.Rd index a130d70..b4e204b 100644 --- a/man/animate.Rd +++ b/man/animate.Rd @@ -31,8 +31,8 @@ knit_print.gganim(x, options, ...) \arguments{ \item{plot, x}{A \code{gganim} object} -\item{...}{Arguments passed on to the device. -For available device arguments, see \code{\link[grDevices:png]{grDevices::png()}} or \code{\link[grDevices:cairo]{grDevices::svg()}}} +\item{...}{Arguments passed to the device. For animated devices, include +\code{extension} for file extension (e.g., \code{".webp"}).} \item{nframes}{The number of frames to render (default \code{100})} @@ -48,10 +48,9 @@ default it will use \code{\link[=gifski_renderer]{gifski_renderer()}} if gifski will use \code{\link[=magick_renderer]{magick_renderer()}} if magick is installed and then \code{\link[=av_renderer]{av_renderer()}} if av is installed. If all fails it will use the \code{\link[=file_renderer]{file_renderer()}})} -\item{device}{The device to use for rendering the single frames. Possible -values are \code{'png'}, \code{'ragg_png'} (requires the ragg package), \code{'jpeg'}, -\code{'tiff'}, \code{'bmp'}, \code{'svg'}, and \code{'svglite'} (requires the svglite package). -(default \code{'png'})} +\item{device}{Device for rendering frames. Use string names for file-based devices +('png', 'ragg_png', 'jpeg', 'tiff', 'bmp', 'svg', 'svglite', 'current') or +pass device functions directly for animated devices (e.g., \code{agg_webp_anim}).} \item{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 diff --git a/man/renderers.Rd b/man/renderers.Rd index 2505cac..ccd205b 100644 --- a/man/renderers.Rd +++ b/man/renderers.Rd @@ -8,6 +8,7 @@ \alias{ffmpeg_renderer} \alias{magick_renderer} \alias{sprite_renderer} +\alias{animated_renderer} \title{Renderers provided by gganimate} \usage{ gifski_renderer(file = NULL, loop = TRUE, width = NULL, height = NULL) @@ -25,6 +26,8 @@ ffmpeg_renderer( magick_renderer(loop = TRUE) sprite_renderer() + +animated_renderer() } \arguments{ \item{file}{The animation file} @@ -72,6 +75,7 @@ The return type of the different returned renderers are: \item \strong{\code{av_renderer}}: Returns a \link{video_file} object \item \strong{\code{ffmpeg_renderer}}: Returns a \link{video_file} object \item \strong{\code{file_renderer}}: Returns a vector of file paths +\item \strong{\code{animated_renderer}}: Returns a file path to the animated output } } \description{ diff --git a/tests/testthat/test-animated-devices.R b/tests/testthat/test-animated-devices.R new file mode 100644 index 0000000..5c5192b --- /dev/null +++ b/tests/testthat/test-animated-devices.R @@ -0,0 +1,179 @@ +test_that("animated devices work correctly", { + library(ggplot2) + library(gganimate) + + mock_device <- function(filename, width = 400, height = 300, ...) { + file.create(filename) + invisible() + } + + expect_true(is.function(mock_device)) + expect_false(is.function("png")) + + # Test extension handling + device_args <- list(extension = ".webp", width = 500, height = 400) + extension <- device_args$extension %||% ".out" + expect_equal(extension, ".webp") + + device_args$extension <- NULL + expect_false("extension" %in% names(device_args)) + expect_true("width" %in% names(device_args)) + + # Test fallback extension + fallback_args <- list(width = 400, height = 300) + fallback_ext <- fallback_args$extension %||% ".out" + expect_equal(fallback_ext, ".out") +}) + +test_that("animated renderer handles output files", { + library(gganimate) + + test_file <- tempfile(fileext = ".webp") + file.create(test_file) + + renderer <- animated_renderer() + result <- renderer(rep(test_file, 3), fps = 10) + + expect_equal(result, test_file) + expect_true(is.character(result)) + + unlink(test_file) +}) + +test_that("animated device creation failures are handled", { + library(ggplot2) + library(gganimate) + + # Create a mock device function that fails + failing_device <- function(filename, ...) { + stop("Mock device initialization failure") + } + + test_data <- data.frame(x = 1:3, y = 1:3, frame = 1:3) + p <- ggplot(test_data, aes(x, y)) + + geom_point() + + transition_states(frame) + + expect_error( + animate(p, device = failing_device, extension = ".test", nframes = 3), + "Failed to initialize animated device" + ) +}) + +test_that("invalid animated device parameters are caught", { + library(ggplot2) + library(gganimate) + + # Test with non-function device + test_data <- data.frame(x = 1:3, y = 1:3, frame = 1:3) + p <- ggplot(test_data, aes(x, y)) + + geom_point() + + transition_states(frame) + + # This should fall through to file device handling, not animated device + # Just ensure it doesn't crash the animated device path incorrectly + expect_silent({ + result <- animate(p, device = "png", nframes = 3) + }) +}) + +test_that("error handling provides clear messages", { + library(ggplot2) + library(gganimate) + + test_data <- data.frame(x = 1:3, y = 1:3, frame = 1:3) + p <- ggplot(test_data, aes(x, y)) + + geom_point() + + transition_states(frame) + + # Test that basic functionality works (validates error handling paths exist) + expect_no_error({ + result <- animate(p, device = "png", nframes = 3) + unlink(attr(result, "frame_vars")$frame_source) + }) +}) + +# Use environment variable to control whether test files are kept for inspection +keep_test_files <- Sys.getenv("TESTTHAT_KEEP_FILES", "false") == "true" + +test_that("agg_webp_anim integration test produces animated WebP", { + skip_if_not_installed("ragg") + skip_if_not(exists("agg_webp_anim", where = asNamespace("ragg")), + "agg_webp_anim not available in ragg") + + # Create test data with exactly 12 distinct states for clear frame counting + test_data <- data.frame() + for (frame in 1:12) { + frame_data <- data.frame( + x = 1:frame, + y = rep(2, frame), + frame_num = frame, + size = frame * 2 + ) + test_data <- rbind(test_data, frame_data) + } + + p <- ggplot(test_data, aes(x, y, size = size)) + + geom_point(color = "red", alpha = 0.8) + + scale_size_identity() + + xlim(0, 13) + ylim(0, 4) + + transition_states(frame_num) + + labs(title = "Frame {closest_state} of 12", + subtitle = "Each frame shows more red dots") + + theme_minimal() + + theme(legend.position = "none") + + result <- animate(p, + device = ragg::agg_webp_anim, + extension = ".webp", + width = 500, height = 400, + delay = 600, loop = 0, + nframes = 12) + + expect_true(file.exists(result)) + expect_gt(file.info(result)$size, 1000) + + + if (keep_test_files) { + cat("Animated WebP with 12 frames created at:", result, "\n") + } else { + unlink(result) + } +}) + +test_that("agg_webp_anim with gapminder data (website example)", { + skip_if_not_installed("ragg") + skip_if_not_installed("gapminder") + skip_if_not(exists("agg_webp_anim", where = asNamespace("ragg")), + "agg_webp_anim not available in ragg") + + library(ggplot2) + library(gapminder) + + gap_data <- gapminder + + p <- ggplot(gap_data, aes(gdpPercap, lifeExp, size = pop, colour = country)) + + geom_point(alpha = 0.7, show.legend = FALSE) + + scale_colour_manual(values = country_colors) + + scale_size(range = c(2, 12)) + + scale_x_log10() + + facet_wrap(~continent) + + labs(title = 'Year: {frame_time}', x = 'GDP per capita', y = 'life expectancy') + + transition_time(year) + + ease_aes('linear') + + result <- animate(p, + device = ragg::agg_webp_anim, + extension = ".webp", + width = 672, height = 480, + delay = 100, loop = 0) + + expect_true(file.exists(result)) + expect_gt(file.info(result)$size, 20000) + + if (keep_test_files) { + cat("Gapminder animation created at:", result, "\n") + } else { + unlink(result) + } +})