Stop motion editing with stopmotion: the laser dinosaur

## Credit

The dinosaur animation used throughout this vignette was created by @looksrawr and is released under the CC0 1.0 Universal Public Domain Dedication.

## 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:

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.

    dino_dir <- system.file("extdata", package = "stopmotion")
    dino <- read(dir = dino_dir)
    dino |> preview(fps = 2)

    cat("Number of frames:", length(dino), "\n")
    #> Number of frames: 10
    image_info(dino)[, c("width", "height", "filesize")]
    #>    width height filesize
    #> 1    480    480     5430
    #> 2    480    480     5392
    #> 3    480    480     5496
    #> 4    480    480     9207
    #> 5    480    480    11016
    #> 6    480    480    11595
    #> 7    480    480    10458
    #> 8    480    480    10736
    #> 9    480    480     6515
    #> 10   480    480     6450

Ten frames, 480 × 480 pixels.

## 2 Inspecting frames

`montage` is a quick way to display all frames side-by-side.

    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.

    dino_w <- wiggle(dino, degrees = 2, frames = 1:3)
    cat("Total frames after wiggle():", length(dino_w), "\n")
    #> Total frames after wiggle(): 16

## 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.

    dino2 <- duplicate(dino, frames = 5:6, style = "looped")
    cat("Frames after duplicate():", length(dino2), "\n")
    #> Frames after duplicate(): 12

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.

    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.

    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.

    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).

    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

    # Frames 1–2: mirror horizontally so the dino faces left (run-up)
    dino_s <- flop(dino, frames = 1:2)

    # Frame 3: rotate 90° — leaning forward into the jump
    dino_s <- rotate(dino_s, degrees = 90, frames = 3L)

    # Frame 4: flip vertically — upside-down at the apex of the somersault
    dino_s <- flip(dino_s, frames = 4L)

    # Frame 5: rotate 270° — coming back around to land upright
    dino_s <- rotate(dino_s, degrees = 270, frames = 5L)

    # 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")
    #> Frames after duplication: 15

The five steps collapse into a single pipe:

    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")

    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:

    # 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.

    dino_small <- scale(dino, geometry = "50%")

### 10.3 Cropping to the face

    # 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:

    # 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.

    # 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:

    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.