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.InvalidRequestErrorwhose:reasonis{: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-emptykeyword/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:cropthe variant is square.:crop- Fills the width and the height exactly and cuts off the rest::centerkeeps the middle,:attentionthe 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, ornilfor a path that gets no variants, such as a variant's own path.formatis the variant's:formatornil. A{module, function, args}is called withargsafter those three, for disks from config. The default puts them underthumbnails/(seeprefixed/4and Where variants go). The default value is{Fil.Plugin.Thumbnails, :prefixed, ["thumbnails"]}.:extensions(list ofString.t/0) - The extensions of images, lowercase with the dot. A path's extension is compared in lowercase, sophoto.JPGis an image too. The default value is[".jpg", ".jpeg", ".png", ".webp"].:mode-:on_writemakes the variants when an image is written.:manualdoesn't, and your code callsgenerate/1instead, 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 is100000000.
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
@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.
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.
@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.
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")
nilAnother directory is variant_path: {Fil.Plugin.Thumbnails, :prefixed, ["media/thumbs"]}.
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.
@spec variant(Fil.Disk.t(), Path.t(), atom()) :: Fil.Ref.t()
Returns the ref of a variant of an image. See variant/2.