--- title: "Stop motion editing with stopmotion: the laser dinosaur" vignette: > %\VignetteIndexEntry{Stop motion editing with stopmotion: the laser dinosaur} %\VignetteEngine{quarto::html} %\VignetteEncoding{UTF-8} format: html: toc: true knitr: opts_chunk: collapse: true warning: false comment: "#>" fig.align: center eval: false --- ## Credit The dinosaur animation used throughout this vignette was created by **\@looksrawr** and is released under the [CC0 1.0 Universal Public Domain Dedication](https://creativecommons.org/publicdomain/zero/1.0/). * * * ## Overview **stopmotion** is a pipeline-friendly toolkit for assembling and editing stop motion animations from sequences of still images. Its functions fall into three families: | Family | Functions | |---|---| | Load | `read()` | | Restructure | `duplicate()`, `splice()` | | Transform | `rotate()`, `wiggle()`, `flip()`, `flop()`, `blur()`, `scale()`, `crop()`, `trim()`, `border()`, `background()`, `centre()` | | Display | `montage()`, `preview()` | All functions accept an optional `frames` argument so that any operation can target a precise subset of frames — the feature that sets **stopmotion** apart from plain **magick** pipelines. After each operation **stopmotion** prints a message listing the updated frame sequence. These messages are shown in interactive sessions and suppressed automatically during document rendering. To control verbosity explicitly, use `stopmotion_verbosity()` or set the option directly: ```{r options} #| eval: true #| include: false library(magick) library(stopmotion) stopmotion_verbosity(FALSE) # silence for the rest of the session options(stopmotion.verbose = FALSE) # equivalent ``` Or edit your `.Rprofile` to save the option with `usethis::edit_r_profile()` This vignette walks through a complete editing session using the bundled `extdata/` frames: a ten-frame cartoon dinosaur whose eyes shoot a laser ray. * * * ## 1 Loading a GIF The bundled `extdata/` directory contains the ten frames of the dinosaur animation as individual PNG files — exactly the kind of image sequence that **stopmotion** is designed to work with. `read()` loads them in lexicographic order and returns a `magick-image` object accepted by every **stopmotion** function. ```{r load} #| eval: true dino_dir <- system.file("extdata", package = "stopmotion") dino <- read(dir = dino_dir) dino |> preview(fps = 2) ``` ```{r frame-count} #| eval: true cat("Number of frames:", length(dino), "\n") image_info(dino)[, c("width", "height", "filesize")] ``` Ten frames, 480 × 480 pixels. * * * ## 2 Inspecting frames `montage` is a quick way to display all frames side-by-side. ```{r montage} #| eval: true #| fig-width: 7 #| fig-height: 2.5 montage(dino, tile = "10x1", geometry = "64x64+2+2") ``` Scanning left to right you can read the story: * **Frames 1–4** — the dinosaur stands still, then opens its eyes. * **Frames 5–6** — eyes glow and start charging up. * **Frames 7–9** — the laser fires. * **Frame 10** — the beam fades and the dinosaur looks pleased. * * * ## 3 Hand-held shake with `wiggle()` `wiggle()` inserts two slightly rotated copies after each selected frame — one tilted `+degrees`, one `−degrees` — creating the organic wobble typical of real stop motion. We apply it to the quieter frames at the start so the calm contrast with the explosive laser section. ```{r wiggle} #| eval: true dino_w <- wiggle(dino, degrees = 2, frames = 1:3) cat("Total frames after wiggle():", length(dino_w), "\n") ``` * * * ## 4 Building anticipation with `duplicate()` The charging phase (frames 5–6) flashes by too quickly. `duplicate()` with `style = "looped"` inserts a copy of those frames immediately after the originals, making the build-up feel more deliberate. ```{r dup-frames} #| eval: true dino2 <- duplicate(dino, frames = 5:6, style = "looped") cat("Frames after duplicate():", length(dino2), "\n") ``` The `looped` style repeats the selected range in order, so the sequence becomes …5, 6, 5, 6… — a natural charging pulse. * * * ## 5 Adding drama with `border()` A vivid red border on the laser frames (now frames 7–11 after the insertion) signals "danger" to the viewer. ```{r border} #| eval: true dino3 <- border(dino2, color = "red", geometry = "8x8", frames = 7:11) ``` * * * ## 6 Motion blur on the laser beam with `blur()` The three peak laser frames (frames 8–10) benefit from a subtle blur that conveys raw energy. ```{r blur} #| eval: true dino4 <- blur(dino3, radius = 3, sigma = 1.5, frames = 8:10) ``` * * * ## 7 Putting it together: the full pipeline The edits above can be chained into a single pipe for clarity. ```{r pipeline} #| eval: true read(dir = system.file("extdata", package = "stopmotion")) |> wiggle(degrees = 2, frames = 1:3) |> # hand-held shake duplicate(frames = 5:6, style = "looped") |> # hold the charge border(color = "red", geometry = "8x8", frames = 7:11) |> # danger border blur(radius = 3, sigma = 1.5, frames = 8:10) |> # energy blur preview(fps = 2) ``` * * * ## 8 Exporting the result `magick::image_write_gif()` writes the edited sequence back to a GIF file. The `delay` argument controls playback speed (seconds per frame). ```{r export} out <- tempfile(fileext = ".gif") image_write_gif(dino_final, path = out, delay = 1 / 8) message("Saved to: ", out) ``` * * * ## 9 The celebratory somersault: `flip()`, `flop()`, and `rotate()` Once the laser fades, the dinosaur celebrates with a somersault. This example chains three pure geometric transforms — each applied to a precise subset of frames — to choreograph a full forward flip. | Step | Function | Visual effect | |---|---|---| | Mirror left–right | `flop()` | Dino faces left for the run-up | | Lean forward 90° | `rotate()` | Start of the forward flip | | Upside-down apex | `flip()` | Top-to-bottom mirror at the peak | | Lean back 270° | `rotate()` | Completing the circle | | Loop it twice | `duplicate()` | Two full somersaults | ```{r somersault-flop} #| eval: true # Frames 1–2: mirror horizontally so the dino faces left (run-up) dino_s <- flop(dino, frames = 1:2) ``` ```{r somersault-rotate1} #| eval: true # Frame 3: rotate 90° — leaning forward into the jump dino_s <- rotate(dino_s, degrees = 90, frames = 3L) ``` ```{r somersault-flip} #| eval: true # Frame 4: flip vertically — upside-down at the apex of the somersault dino_s <- flip(dino_s, frames = 4L) ``` ```{r somersault-rotate2} #| eval: true # Frame 5: rotate 270° — coming back around to land upright dino_s <- rotate(dino_s, degrees = 270, frames = 5L) ``` ```{r somersault-loop} #| eval: true # Duplicate the spin frames so the dino does two full somersaults dino_s <- duplicate(dino_s, frames = 1:5, style = "looped") cat("Frames after duplication:", length(dino_s), "\n") ``` The five steps collapse into a single pipe: ```{r somersault-pipeline} #| eval: true dino_somersault <- dino |> flop(frames = 1:2) |> # run-up: face left rotate(degrees = 90, frames = 3L) |> # lean into the jump flip(frames = 4L) |> # upside-down apex rotate(degrees = 270, frames = 5L) |> # complete the circle duplicate(frames = 1:5, style = "looped") # loop it twice montage(dino_somersault[1:10], tile = "10x1", geometry = "64x64+2+2") ``` ```{r somersault-preview} #| eval: true dino_somersault |> preview(fps = 2) ``` * * * ## 10 Other useful operations ### 10.1 Dropping unwanted frames with `splice()` `splice()` inserts new frames after a given position. Combined with standard **magick** subsetting you can also remove frames: ```{r splice} # Insert a custom "RAWR!" title card after frame 4 title_card <- image_blank(480, 480, color = "black") |> image_annotate("RAWR!", size = 80, color = "red", gravity = "Center") dino_with_title <- splice(dino, insert = title_card, after = 4L) ``` ### 10.2 Scaling down for the web `scale()` accepts any **magick** geometry string. ```{r scale} dino_small <- scale(dino, geometry = "50%") ``` ### 10.3 Cropping to the face ```{r crop} # Keep a 200×200 window centred on the head (adjust offsets to taste) dino_face <- crop(dino, geometry = "200x200+140+60") ``` ### 10.4 Aligning frames with `centre()` When frames drift slightly between photos — a common artefact of hand-held stop motion — `centre()` performs a full affine warp (translation, rotation, and scaling simultaneously) so the subject stays locked in place across the whole animation. It needs exactly **two landmarks per frame**: consistent anatomical anchors such as the left and right eye. The `reference` frame defines the target position; every other frame is warped to match it. #### Collecting landmarks interactively The most practical way to record landmarks is `locator()` from base R. Click the two anchors on each frame in turn (always in the same order), and build up the data frame row by row. `locator()` returns `y` measured from the **bottom** edge of the plot, which is the convention `centre()` expects: ```{r centre-locator} # Run once per editing session — requires an interactive graphics device. # Display each frame, click the two landmarks, store the coordinates. pts_list <- lapply(seq_along(dino), function(i) { plot(as.raster(dino[i])) # display frame i message("Frame ", i, ": click LEFT eye then RIGHT eye") p <- locator(2L) # two clicks; y is from the bottom edge data.frame(frame = i, x = p$x, y = p$y) }) pts <- do.call(rbind, pts_list) ``` #### A worked example with artificial drift The bundled dino is a clean digital sprite with no accidental drift, so the example below **introduces** known drift first via a pure-translation affine warp, then corrects it — making the fix unambiguous. Frame 2 is shifted +5 px right / +3 px down; frame 3 is shifted −4 px left / +2 px down (all in ImageMagick's top-edge coordinate system used by `image_distort`). The landmark table is in the bottom-edge convention that `centre()` expects. ```{r centre} #| eval: true # Introduce known translational drift. Two widely-spaced control-point pairs # both encoding the same displacement define a pure translation. # Coordinates are in ImageMagick top-edge convention for image_distort. dino_d <- c( dino[1], magick::image_distort(dino[2], "Affine", # +5 right, +3 down c(100, 100, 105, 103, 380, 380, 385, 383)), magick::image_distort(dino[3], "Affine", # −4 left, +2 down c(100, 100, 96, 102, 380, 380, 376, 382)), dino[4:10] ) # Eye positions in the drifted sequence — y from the bottom edge (locator convention). # Frame 1 reference (unchanged): left (212, 271), right (272, 270). # Frame 2 shifted (+5 right, +3 down): left (217, 268), right (277, 267). # Frame 3 shifted (−4 left, +2 down): left (208, 269), right (268, 268). pts <- data.frame( frame = c(1L, 1L, 2L, 2L, 3L, 3L), x = c(212, 272, 217, 277, 208, 268), y = c(271, 270, 268, 267, 269, 268) ) # Correct only the drifted frames; leave 4–10 untouched. dino_stabilised <- centre(dino_d, points = pts, reference = 1L, frames = 2:3) ``` Compare the original drifted sequence with the stabilised one: ```{r centre-compare} #| eval: true montage(dino_d[1:3], tile = "3x1", geometry = "128x128+2+2") montage(dino_stabilised[1:3], tile = "3x1", geometry = "128x128+2+2") ``` * * * ## Summary | Step | Function | Key argument | |---|---|---| | Hold charging frames | `duplicate()` | `style = "looped"` | | Red danger border | `border()` | `color`, `geometry` | | Energy motion blur | `blur()` | `radius`, `sigma` | | Hand-held shake | `wiggle()` | `degrees` | | Insert title card | `splice()` | `insert`, `after` | | Resize for web | `scale()` | `geometry` | | Crop to face | `crop()` | `geometry` | | Stabilise frames | `centre()` | `points`, `reference` | | Mirror left–right | `flop()` | `frames` | | Mirror top–bottom | `flip()` | `frames` | | Rotate by angle | `rotate()` | `degrees` | All of the above accept a `frames` argument to restrict the operation to any subset of frames, giving you frame-precise control over your animation.