Packages

phoenix_kit

2.41.1
2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules storage variant_sets.ex
Raw

lib/modules/storage/variant_sets.ex

defmodule PhoenixKit.Modules.Storage.VariantSets do
@moduledoc """
Variant sets: which derived files a library's uploads get (V205).
A library points at a set (`PhoenixKit.Modules.Storage.VariantSet`), and
the set's sizes are its `PhoenixKit.Modules.Storage.Dimension` rows. A
library with no set uses the **Default**, seeded by V205 with every size
the install had, under a fixed uuid (`default_uuid/0`). Size names are
unique per set, so two sets can each have a `thumbnail` of a different
size.
## Standard slots
A size's name is part of every file URL, and core and modules ask for
some by name, so every set has the five standard slots
(`standard_slots/0`): `thumbnail`, `small`, `medium`, `large` and
`video_thumbnail`. A set may change their size, format and quality, but
not delete or rename them, and `small`, `medium` and `large` keep the
aspect ratio (`aspect_slots/0`); only `thumbnail` may be cropped. A new
set starts with the Default's standard slots.
## Spec hash
A generated instance records which spec of its size made it
(`FileInstance.spec_hash`, `spec_hash/2`), and a set records a `revision`
bumped on every change to it or its sizes. A file records the set and
revision its variants were made by (`placed_variant_set_uuid` /
`placed_variant_revision`, NULL meaning the Default at revision 1). The
reconciler regenerates the instances of a stale file whose spec changed.
"""
import Ecto.Query
alias PhoenixKit.Modules.Storage.{Dimension, Library, VariantSet}
alias PhoenixKit.Modules.Storage.File, as: StorageFile
alias PhoenixKit.Modules.Storage.Libraries
alias PhoenixKit.Modules.Storage.Workers.ReconcileJob
alias PhoenixKit.Settings
@default_uuid "00000000-0000-7000-8000-000000000003"
@doc "The uuid of the Default variant set, fixed on every install."
@spec default_uuid() :: String.t()
def default_uuid, do: @default_uuid
@doc "Whether `uuid` is the Default set's."
@spec default?(term()) :: boolean()
def default?(%VariantSet{uuid: uuid}), do: default?(uuid)
def default?(uuid), do: to_string(uuid) == @default_uuid
@doc "The sizes every set has."
@spec standard_slots() :: [String.t()]
def standard_slots, do: Dimension.standard_slots()
@doc "The standard slots that always keep the aspect ratio."
@spec aspect_slots() :: [String.t()]
def aspect_slots, do: Dimension.aspect_slots()
@doc "Every set, the Default first."
@spec list_variant_sets() :: [VariantSet.t()]
def list_variant_sets do
from(s in VariantSet, order_by: [desc: s.is_default, asc: fragment("lower(?)", s.name)])
|> repo().all()
end
@doc "The sets a user library may choose."
@spec list_selectable() :: [VariantSet.t()]
def list_selectable do
from(s in VariantSet,
where: s.selectable or s.is_default,
order_by: [desc: s.is_default, asc: fragment("lower(?)", s.name)]
)
|> repo().all()
end
@doc "A set, or nil (also for anything not a uuid)."
@spec get_variant_set(term()) :: VariantSet.t() | nil
def get_variant_set(uuid) do
case Ecto.UUID.cast(uuid) do
{:ok, uuid} -> repo().get(VariantSet, uuid)
:error -> nil
end
end
@doc "The Default set."
@spec default_variant_set() :: VariantSet.t() | nil
def default_variant_set, do: get_variant_set(@default_uuid)
@doc """
The uuid of the set `library` uses: its own, or the Default. Takes a
library, a library uuid, or nil (a file with no library is in Media).
"""
@spec set_uuid_for(Library.t() | term()) :: String.t()
def set_uuid_for(%Library{variant_set_uuid: nil}), do: @default_uuid
def set_uuid_for(%Library{variant_set_uuid: uuid}), do: to_string(uuid)
def set_uuid_for(nil), do: set_uuid_for(Libraries.media_uuid())
def set_uuid_for(library_uuid) do
case Libraries.get_library(library_uuid) do
%Library{} = library -> set_uuid_for(library)
nil -> @default_uuid
end
end
@doc "The set `library` uses; the Default when its own is gone."
@spec for_library(Library.t() | term()) :: VariantSet.t() | nil
def for_library(library) do
library |> set_uuid_for() |> get_variant_set() || default_variant_set()
end
@doc "The set a file's library uses."
@spec for_file(StorageFile.t()) :: VariantSet.t() | nil
def for_file(%StorageFile{library_uuid: library_uuid}), do: for_library(library_uuid)
@doc "A set's sizes, ordered as the admin arranged them."
@spec list_dimensions(term()) :: [Dimension.t()]
def list_dimensions(set_uuid) do
from(d in Dimension,
where: d.variant_set_uuid == ^set_uuid,
order_by: [asc: d.order, asc: d.name]
)
|> repo().all()
end
@doc "The standard slots `set_uuid` has no size for."
@spec missing_standard_slots(term()) :: [String.t()]
def missing_standard_slots(set_uuid) do
present =
from(d in Dimension, where: d.variant_set_uuid == ^set_uuid, select: d.name)
|> repo().all()
standard_slots() -- present
end
@doc """
Creates a set. It starts with a copy of the Default's standard slots,
so it keeps the contract every set has.
"""
@spec create_variant_set(map()) :: {:ok, VariantSet.t()} | {:error, Ecto.Changeset.t()}
def create_variant_set(attrs) do
changeset = VariantSet.changeset(%VariantSet{}, attrs)
transact(fn ->
with {:ok, set} <- repo().insert(changeset) do
copy_standard_slots(set.uuid)
{:ok, set}
end
end)
end
defp copy_standard_slots(set_uuid) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
from(d in Dimension,
where: d.variant_set_uuid == ^@default_uuid and d.name in ^standard_slots()
)
|> repo().all()
|> Enum.map(fn d ->
d
|> Map.take([
:name,
:width,
:height,
:quality,
:format,
:applies_to,
:enabled,
:maintain_aspect_ratio,
:alternative_formats,
:order
])
|> Map.merge(%{
uuid: UUIDv7.generate(),
variant_set_uuid: set_uuid,
inserted_at: now,
updated_at: now
})
end)
repo().insert_all(Dimension, rows)
end
@doc """
Updates a set's name or flags. Turning variant or tile generation on or
off bumps its revision; a rename or `selectable` does not.
"""
@spec update_variant_set(VariantSet.t(), map()) ::
{:ok, VariantSet.t()} | {:error, Ecto.Changeset.t()}
def update_variant_set(%VariantSet{} = set, attrs) do
changeset = VariantSet.changeset(set, attrs)
transact(fn ->
with {:ok, updated} <- repo().update(changeset) do
if Map.drop(changeset.changes, [:name, :selectable]) != %{},
do: bump_revision(updated.uuid)
{:ok, updated.uuid}
end
end)
|> case do
{:ok, uuid} ->
set = get_variant_set(uuid)
if set.is_default, do: sync_settings(set)
{:ok, set}
error ->
error
end
end
# The Default's flags are what two settings were before variant sets; the
# rows are kept in step for code that still reads them.
defp sync_settings(%VariantSet{} = set) do
Settings.update_setting("storage_auto_generate_variants", to_string(set.generate_variants))
Settings.update_setting("storage_tile_generation_enabled", to_string(set.generate_tiles))
end
@doc """
Deletes a set and its sizes. The Default cannot be deleted
(`{:error, :default}`), nor a set a library uses (`{:error, :in_use}`).
"""
@spec delete_variant_set(VariantSet.t()) ::
{:ok, VariantSet.t()} | {:error, :default | :in_use | Ecto.Changeset.t()}
def delete_variant_set(%VariantSet{} = set) do
cond do
default?(set) ->
{:error, :default}
libraries_using(set.uuid) > 0 ->
{:error, :in_use}
true ->
set
|> Ecto.Changeset.change()
|> Ecto.Changeset.foreign_key_constraint(:uuid,
name: :phoenix_kit_storage_libraries_variant_set_fkey,
message: "is used by a library"
)
|> repo().delete()
end
end
@doc "How many libraries (trashed ones included) use `set_uuid` explicitly."
@spec libraries_using(term()) :: non_neg_integer()
def libraries_using(set_uuid) do
from(l in Library, where: l.variant_set_uuid == ^set_uuid, select: count())
|> repo().one()
end
@doc """
Points `library` at `set_uuid` (nil for the Default). A user library may
only use a selectable set (`{:error, :not_selectable}`).
"""
@spec set_library_variant_set(Library.t(), term()) ::
{:ok, Library.t()} | {:error, Ecto.Changeset.t() | :not_found | :not_selectable}
def set_library_variant_set(%Library{} = library, set_uuid) do
set_uuid = if default?(set_uuid), do: nil, else: set_uuid
set = set_uuid && get_variant_set(set_uuid)
cond do
set_uuid && is_nil(set) ->
{:error, :not_found}
library.kind == "user" and match?(%VariantSet{selectable: false}, set) ->
{:error, :not_selectable}
true ->
library
|> Ecto.Changeset.change(variant_set_uuid: set_uuid)
|> Ecto.Changeset.foreign_key_constraint(:variant_set_uuid,
name: :phoenix_kit_storage_libraries_variant_set_fkey
)
|> repo().update()
|> tap(&if(match?({:ok, _}, &1), do: ReconcileJob.enqueue()))
end
end
@doc "Bumps a set's revision: every file whose variants it made is stale."
@spec bump_revision(term()) :: :ok
def bump_revision(set_uuid) do
from(s in VariantSet, where: s.uuid == ^set_uuid)
|> repo().update_all(
inc: [revision: 1],
set: [updated_at: DateTime.truncate(DateTime.utc_now(), :second)]
)
ReconcileJob.enqueue()
:ok
end
@doc """
Whether the set of `file`'s library makes zoomable tiles. Takes a file
or a file uuid; false for an unknown file.
"""
@spec tiles_for?(StorageFile.t() | term()) :: boolean()
def tiles_for?(%StorageFile{library_uuid: library_uuid}),
do: library_flag(library_uuid, :generate_tiles)
def tiles_for?(file_uuid) do
case Ecto.UUID.cast(file_uuid) do
{:ok, uuid} ->
from(f in StorageFile,
left_join: l in Library,
on: l.uuid == f.library_uuid,
join: s in VariantSet,
on: s.uuid == coalesce(l.variant_set_uuid, type(^@default_uuid, UUIDv7)),
where: f.uuid == ^uuid,
select: s.generate_tiles
)
|> repo().one() == true
:error ->
false
end
end
@doc """
Of `library_uuids` (nil is Media), the ones whose set makes tiles, as a
set of strings: one query for a page of files.
"""
@spec tiles_among([term()]) :: MapSet.t(String.t())
def tiles_among(library_uuids) do
uuids =
library_uuids
|> Enum.map(&(&1 || Libraries.media_uuid()))
|> Enum.flat_map(&List.wrap(cast(&1)))
|> Enum.uniq()
from(l in Library,
join: s in VariantSet,
on: s.uuid == coalesce(l.variant_set_uuid, type(^@default_uuid, UUIDv7)),
where: l.uuid in ^uuids and s.generate_tiles,
select: l.uuid
)
|> repo().all()
|> MapSet.new(&to_string/1)
end
@doc "Whether the set of `file`'s library makes sizes automatically."
@spec variants_for?(StorageFile.t()) :: boolean()
def variants_for?(%StorageFile{library_uuid: library_uuid}),
do: library_flag(library_uuid, :generate_variants)
defp library_flag(library_uuid, flag) do
case for_library(library_uuid) do
%VariantSet{} = set -> Map.fetch!(set, flag)
nil -> false
end
end
@doc """
One of the Default set's flags (`:generate_variants` or
`:generate_tiles`): what the `storage_auto_generate_variants` and
`storage_tile_generation_enabled` settings were before variant sets.
`default` when there is no Default set yet.
"""
@spec default_flag(:generate_variants | :generate_tiles, boolean()) :: boolean()
def default_flag(flag, default) when flag in [:generate_variants, :generate_tiles] do
from(s in VariantSet, where: s.uuid == ^@default_uuid, select: field(s, ^flag))
|> repo().one()
|> case do
nil -> default
value -> value
end
end
@doc """
Records that `file`'s variants were made by its library's set as it is
now (`complete?`), or that some are missing (the file is stale for the
reconciler: `placed_variant_revision = 0`).
"""
@spec record_variants(StorageFile.t(), boolean(), VariantSet.t() | nil | :current) :: :ok
def record_variants(%StorageFile{} = file, complete?, set \\ :current) do
set = if set == :current, do: for_file(file), else: set
changes =
if set && complete?,
do: [placed_variant_set_uuid: set.uuid, placed_variant_revision: set.revision],
else: [placed_variant_revision: 0]
from(f in StorageFile, where: f.uuid == ^file.uuid)
|> repo().update_all(set: changes)
unless complete?, do: ReconcileJob.enqueue()
:ok
end
@image_formats ~w(jpg jpeg png webp avif gif)
@doc """
What to serve in place of `variant` of `file` while it has not been made
(G17), given the file's `instances`:
* `{:instance, instance}` — the nearest smaller size the file has;
* `:placeholder` — no smaller size exists, and the original is larger
than the size asked for;
* `:original` — the original is no larger than the size, or `variant`
is not an image size of the file's set (an unknown name, a video
transcode), which is what was always served.
A thumbnail-class request never gets a full-size original: in a grid of
thousands, a new or renamed size would otherwise send thousands of them.
"""
@spec stand_in(StorageFile.t(), String.t(), [struct()]) ::
{:instance, struct()} | :placeholder | :original
def stand_in(%StorageFile{} = file, variant, instances) do
set = for_library(file.library_uuid)
# Only a size that will be made: with the set making no sizes, a
# disabled size, or one for the other kind of file, nothing is coming,
# and the original is what was always served.
with %VariantSet{generate_variants: true} <- set,
%Dimension{width: width, enabled: true} = dimension when is_integer(width) <-
size_named(set.uuid, variant),
true <- dimension.applies_to in [file_kind(file), "both"],
true <- image_output?(file, dimension, variant) do
smaller =
instances
|> Enum.filter(fn i ->
i.spec_hash != nil and i.variant_name != variant and
String.starts_with?(i.mime_type || "", "image/") and is_integer(i.width) and
i.width <= width
end)
|> Enum.max_by(& &1.width, fn -> nil end)
cond do
smaller -> {:instance, smaller}
is_integer(file.width) and file.width <= width -> :original
true -> :placeholder
end
else
_ -> :original
end
end
# A size by name, or the size an alternative format (`medium_webp`) is of.
defp size_named(set_uuid, variant) do
dimensions = list_dimensions(set_uuid)
Enum.find(dimensions, &(&1.name == variant)) ||
Enum.find(dimensions, fn d ->
Enum.any?(d.alternative_formats || [], &(variant == "#{d.name}_#{&1}"))
end)
end
defp file_kind(%StorageFile{file_type: "video"}), do: "video"
defp file_kind(_file), do: "image"
defp image_output?(file, dimension, variant) do
alt_format =
Enum.find(dimension.alternative_formats || [], &(variant == "#{dimension.name}_#{&1}"))
format = alt_format || dimension.format
file.file_type in ["image", "document"] or format in @image_formats
end
@doc """
The name of the size of `file`'s set that fits a purpose (G19), rather
than a size picked by name: a set may make `small` a square crop, so a
name alone promises nothing.
## Options
* `:min_width` — the smallest width that will do (default 0);
* `:aspect` — `:preserve` (the aspect ratio is kept), `:crop`, or
`:any` (the default);
* `:output` — `:image` (the default: a still, a video's poster
included) or `:video` (a transcode, for a video file).
The narrowest enabled size of the file's kind that is at least
`:min_width` wide; the widest one when none is; `"original"` when the set
has none that fit.
"""
@spec variant_for(StorageFile.t(), keyword()) :: String.t()
def variant_for(%StorageFile{} = file, opts \\ []) do
min_width = Keyword.get(opts, :min_width, 0)
aspect = Keyword.get(opts, :aspect, :any)
output = Keyword.get(opts, :output, :image)
kind = file_kind(file)
candidates =
file.library_uuid
|> set_uuid_for()
|> list_dimensions()
|> Enum.filter(fn d ->
d.enabled and d.name != "original" and is_integer(d.width) and
d.applies_to in [kind, "both"] and aspect_fits?(d, aspect) and
output_fits?(file, d, output)
end)
case Enum.filter(candidates, &(&1.width >= min_width)) do
[] -> candidates |> Enum.max_by(& &1.width, fn -> nil end) |> name_or_original()
fitting -> fitting |> Enum.min_by(& &1.width) |> name_or_original()
end
end
# An image file's sizes are all stills; a video's are stills only when
# their format is an image format (`video_thumbnail`).
defp output_fits?(%StorageFile{file_type: "video"}, dimension, output) do
still? = dimension.format in @image_formats
if output == :video, do: not still?, else: still?
end
defp output_fits?(_file, _dimension, output), do: output == :image
defp aspect_fits?(_dimension, :any), do: true
defp aspect_fits?(dimension, :preserve), do: dimension.maintain_aspect_ratio == true
defp aspect_fits?(dimension, :crop), do: dimension.maintain_aspect_ratio == false
defp name_or_original(nil), do: "original"
defp name_or_original(%Dimension{name: name}), do: name
defp cast(uuid) do
case Ecto.UUID.cast(uuid) do
{:ok, uuid} -> uuid
:error -> nil
end
end
@doc """
The spec hash of the variant `dimension` makes in `format` (its own
format when not given; nil keeps the original's). Only what changes the
pixels counts: width, height, quality, format and whether the aspect
ratio is kept. V205 stamped existing instances with the same text in SQL
(`PhoenixKit.Migrations.Postgres.V205.spec_hash_sql/1`).
"""
@spec spec_hash(Dimension.t()) :: String.t()
@spec spec_hash(Dimension.t(), String.t() | nil) :: String.t()
def spec_hash(%Dimension{} = dimension), do: spec_hash(dimension, dimension.format)
def spec_hash(%Dimension{} = d, format) do
aspect = if d.maintain_aspect_ratio, do: "t", else: "f"
"v1|w=#{d.width}|h=#{d.height}|q=#{d.quality}|f=#{format}|a=#{aspect}"
|> then(&:crypto.hash(:md5, &1))
|> Base.encode16(case: :lower)
end
defp transact(fun) do
repo().transaction(fn ->
case fun.() do
{:ok, value} -> value
{:error, reason} -> repo().rollback(reason)
end
end)
end
defp repo, do: PhoenixKit.RepoHelper.repo()
end