Fil.Plugin.Thumbnails (Fil v0.2.0)

Copy Markdown View Source

Writes smaller copies of images next to them, resized with libvips.

disk =
  Fil.disk(adapter: Fil.Adapter.S3, bucket: "photos")
  |> Fil.Plugin.Thumbnails.attach(variants: [small: [width: 200], square: [width: 96, crop: :attention]])

Fil.write(disk, "cats/tom.jpg", jpeg)
# also writes thumbnails/small/cats/tom.jpg and thumbnails/square/cats/tom.jpg

disk |> Fil.Plugin.Thumbnails.variant("cats/tom.jpg", :small) |> Fil.url()

It needs the optional dependency Vix, which downloads a precompiled libvips when it's compiled:

{:vix, "~> 0.33"}

Unlike Fil.Plug, which isn't compiled without Plug, the plugin is always there and looks Vix up when it's attached or used. So a disk from config that lists the plugin raises with a message about Vix instead of a missing function, and adding Vix later needs no recompile of fil.

It's also the example of a plugin that needs all of the content at once and writes files of its own (see the Plugins guide).

Variants

Every variant fits into its :width and :height, keeping the image's aspect ratio (or crops to exactly that size with :crop). Images are never enlarged. A variant is rotated by the image's EXIF orientation and keeps only the colour profile of the metadata, so no GPS position ends up in a public thumbnail. That needs libvips 8.15 or later (Vix 0.42 comes with 8.18). On an older libvips, which would keep all of the metadata, the plugin raises instead of running.

A write of an image (by its extension, see :extensions) makes every variant in memory, then writes the image, then the variants, each as a Fil.write/3 with its content type. The variant writes go through the disk's plugins too, and this one passes them on because :variant_path gives them no variants. So Fil.Telemetry counts an image write as one :write plus one per variant, nested in it (see Nested operations). With mode: :manual, a write makes no variants, and generate/1 makes them later.

Where variants go

:variant_path maps the path of an image to the path of each variant. The default, prefixed/4 with "thumbnails", puts the variants of cats/tom.jpg at thumbnails/small/cats/tom.jpg, and a variant with format: :webp at thumbnails/square/cats/tom.jpg.webp. So tom.jpg and tom.png never share a variant, and the content type still follows from the extension. {Fil.Plugin.Thumbnails, :prefixed, ["media/thumbs"]} uses another directory. variant/2 returns the ref of a variant. The variants are ordinary files: read them, build their URLs, and expect them in a recursive Fil.ls/3.

A function of your own can put the variants next to the image instead. This one stores an image as images/5a95_original_158.jpg and its variants as images/5a95_small_158.jpg:

defmodule MyApp.Images do
  def variant_path(path, variant, format) do
    case String.split(path, "_original_", parts: 2) do
      [id, rest] -> "#{id}_#{variant}_#{rest}" <> if(format, do: ".#{format}", else: "")
      [_not_an_original] -> nil
    end
  end
end

Fil.Plugin.Thumbnails.attach(disk,
  variants: [small: [width: 200]],
  variant_path: {MyApp.Images, :variant_path, []}
)

The function returns nil for a path that gets no variants, and that has to include the variants' own paths, or the plugin makes variants of variants. Fil.rm_rf/1 calls it with the directory and a nil format: a path it returns, such as thumbnails/small/cats in the default layout, is deleted with everything under it, and nil deletes nothing more (variants next to their images go with the directory). A path outside the disk root, the root itself or the image's own path raises ArgumentError.

Deletes, copies and renames

Once an image is deleted, copied or renamed on its disk, the plugin does the same with its variants. Fil.rm_rf/1 deletes the variants under the path too. Its count is what the operation itself deleted, so variants only count when they're in the deleted directory.

A copy or a rename to another extension doesn't fit the variants any more: a rename deletes them, and a copy leaves the source's alone. Either deletes the variants the destination had, since they belong to the file it replaced. The same goes for a variant the source doesn't have (an image written before the plugin was attached has none).

Across disks, Fil copies with a read and a write, so a disk with the plugin makes the variants of the copy itself, and a rename deletes the source's variants with the source.

Memory and order

libvips needs the whole image, so an image write collects its content into memory with Fil.Op.materialize/1: up to the whole file per image written at the same time, plus libvips' working memory. Fil.Plug limits uploads with :max_body_size. Every other write stays a stream.

Attach the plugin first, before plugins that rewrite paths or change content (compression, encryption), so it sees the image as the caller wrote it. Its variant writes still go through those plugins. Fil.Plugin.ContentType before it does no harm.

Two writes to the same path at the same time can leave the variants of one next to the image of the other.

Errors

  • content libvips can't decode, or encode as a variant, is refused before anything is written, with a Fil.InvalidRequestError whose :reason is {:not_an_image, message}. The same goes for an image over :max_pixels, with {:too_many_pixels, pixels}
  • when the image can't be written, no variant is written either
  • when a variant can't be written, the image stays written and the write returns the variant's error, with the variant's path. The same goes for a variant that can't be deleted, copied or renamed

Options

  • :variants (non-empty keyword/0) - Required. The variants by name, such as [small: [width: 200], square: [width: 96, crop: :attention]]. A name goes into the variant's path, so it's lowercase letters, digits and underscores. Each variant takes:

    • :width (pos_integer/0) - Required. The largest width in pixels.

    • :height (pos_integer/0) - The largest height in pixels. Without it, only the width limits the size, and with :crop the variant is square.

    • :crop - Fills the width and the height exactly and cuts off the rest: :center keeps the middle, :attention the part libvips finds most likely to draw attention. Without it, the whole image fits into the box.

    • :format - Converts the variant, and appends the format's extension to its path. Without it, the variant has the image's format.

    • :quality - The encoder quality of a JPEG or WebP variant. Without it, libvips' default (75).

  • :variant_path - Where the variants of an image go: a function (path, variant, format) that returns the path of the variant, or nil for a path that gets no variants, such as a variant's own path. format is the variant's :format or nil. A {module, function, args} is called with args after those three, for disks from config. The default puts them under thumbnails/ (see prefixed/4 and Where variants go). The default value is {Fil.Plugin.Thumbnails, :prefixed, ["thumbnails"]}.

  • :extensions (list of String.t/0) - The extensions of images, lowercase with the dot. A path's extension is compared in lowercase, so photo.JPG is an image too. The default value is [".jpg", ".jpeg", ".png", ".webp"].

  • :mode - :on_write makes the variants when an image is written. :manual doesn't, and your code calls generate/1 instead, such as from a background job. Deletes, copies and renames follow in both modes. The default value is :on_write.

  • :max_pixels (pos_integer/0) - The most pixels an image may have. It's read from the image's header, before the image is decoded, so a small file that decodes into a huge image is refused before it takes up memory. The default value is 100000000.

Summary

Functions

Attaches the plugin to disk under the name Fil.Plugin.Thumbnails.

Makes the variants of an image that's already stored, and returns their refs by variant name.

Makes the variants of an image that's already stored. See generate/1.

Puts the variants under prefix, as <prefix>/<variant>/<path>, with the format's extension appended when the variant has a :format. It's the default :variant_path, with "thumbnails". Paths under prefix get no variants, which keeps the plugin from making variants of variants.

Returns the ref of a variant of an image. It's only a path: the variant may not exist.

Returns the ref of a variant of an image. See variant/2.

Functions

attach(disk, opts)

@spec attach(Fil.Disk.t(), keyword()) :: Fil.Disk.t()

Attaches the plugin to disk under the name Fil.Plugin.Thumbnails.

The same as plugins: [{Fil.Plugin.Thumbnails, :call, opts}] in Fil.disk/1, except that the options are validated here instead of on the first operation.

generate(ref)

@spec generate(Fil.Ref.t()) :: {:ok, keyword(Fil.Ref.t())} | {:error, Fil.error()}

Makes the variants of an image that's already stored, and returns their refs by variant name.

{:ok, [small: small, square: square]} = Fil.Plugin.Thumbnails.generate(disk, "cats/tom.jpg")

It reads the image with Fil.read/1, through the disk's plugins, and writes every variant, replacing what's there. Call it with mode: :manual, for images written before the plugin was attached or before a variant was added, and to repair the variants after a write returned a variant's error. An image uploaded to S3 with a presigned URL bypasses the plugins, so it gets its variants this way too.

Returns the errors of Fil.read/1 and those of a write of the image (see Errors). Raises ArgumentError when the plugin isn't attached to the disk, or the path gets no variants: its extension isn't in :extensions, or :variant_path returns nil for it.

generate(disk, path)

@spec generate(Fil.Disk.t(), Path.t()) ::
  {:ok, keyword(Fil.Ref.t())} | {:error, Fil.error()}

Makes the variants of an image that's already stored. See generate/1.

prefixed(path, variant, format, prefix)

@spec prefixed(String.t(), atom(), atom() | nil, String.t()) :: String.t() | nil

Puts the variants under prefix, as <prefix>/<variant>/<path>, with the format's extension appended when the variant has a :format. It's the default :variant_path, with "thumbnails". Paths under prefix get no variants, which keeps the plugin from making variants of variants.

iex> Fil.Plugin.Thumbnails.prefixed("cats/tom.jpg", :small, nil, "thumbnails")
"thumbnails/small/cats/tom.jpg"
iex> Fil.Plugin.Thumbnails.prefixed("cats/tom.jpg", :square, :webp, "thumbnails")
"thumbnails/square/cats/tom.jpg.webp"
iex> Fil.Plugin.Thumbnails.prefixed("thumbnails/small/cats/tom.jpg", :small, nil, "thumbnails")
nil

Another directory is variant_path: {Fil.Plugin.Thumbnails, :prefixed, ["media/thumbs"]}.

variant(ref, name)

@spec variant(Fil.Ref.t(), atom()) :: Fil.Ref.t()

Returns the ref of a variant of an image. It's only a path: the variant may not exist.

iex> disk =
...>   Fil.disk(adapter: Fil.Adapter.Memory)
...>   |> Fil.Plugin.Thumbnails.attach(variants: [small: [width: 200], square: [width: 96, format: :webp]])
iex> Fil.Plugin.Thumbnails.variant(disk, "cats/tom.jpg", :small)
#Fil.Ref<memory:thumbnails/small/cats/tom.jpg>
iex> tom = Fil.ref(disk, "cats/tom.jpg")
iex> Fil.Plugin.Thumbnails.variant(tom, :square)
#Fil.Ref<memory:thumbnails/square/cats/tom.jpg.webp>

Raises ArgumentError when the plugin isn't attached to the disk, name isn't one of its variants, the path escapes the disk root, or :variant_path gives the path no variant.

variant(disk, path, name)

@spec variant(Fil.Disk.t(), Path.t(), atom()) :: Fil.Ref.t()

Returns the ref of a variant of an image. See variant/2.