Packages
phoenix_kit
2.63.0
2.64.1
2.64.0
2.63.0
2.62.2
2.62.1
2.62.0
2.61.0
2.60.3
2.60.2
2.60.1
2.60.0
2.59.0
2.58.0
2.57.1
2.57.0
2.56.1
2.56.0
2.55.1
2.55.0
2.54.2
2.54.1
2.54.0
2.53.0
2.52.2
2.52.1
2.52.0
2.51.0
2.50.0
2.49.1
2.49.0
2.48.0
2.47.0
2.46.0
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
Current section
Files
lib/modules/storage/schemas/dimension.ex
defmodule PhoenixKit.Modules.Storage.Dimension do
@moduledoc """
Schema for dimension presets for automatic file variant generation.
Dimensions define target sizes and quality settings for automatically
generating different versions of uploaded images and videos.
## Fields
- `name` - Unique name for this dimension (e.g., "thumbnail", "medium")
- `width` - Target width in pixels (nullable = maintain aspect ratio)
- `height` - Target height in pixels (nullable = maintain aspect ratio)
- `quality` - Quality setting (1-100 for images, CRF 0-51 for videos)
- `format` - Output format override (nullable = preserve original)
- `applies_to` - What this dimension applies to: "image", "video", or "both"
- `enabled` - Whether this dimension is active
- `order` - Display order in admin interface
## Quality Settings
### Images (JPEG, WebP, PNG)
- Range: 1-100
- 85 = Good quality (default)
- 95 = High quality
- 70 = Medium quality
### Videos (MP4, WebM)
- Range: 0-51 (CRF - Constant Rate Factor)
- Lower = Higher quality
- 23 = Good quality (default)
- 18 = High quality
- 28 = Medium quality
## Examples
# Image thumbnail
%Dimension{
name: "thumbnail",
width: 150,
height: 150,
quality: 85,
format: "jpg",
applies_to: "image",
enabled: true,
order: 1
}
# Video 720p variant
%Dimension{
name: "720p",
width: 1280,
height: 720,
quality: 23,
format: "mp4",
applies_to: "video",
enabled: true,
order: 2
}
# Responsive image (maintain aspect ratio)
%Dimension{
name: "large",
width: 1920,
height: nil, # Maintain aspect ratio
quality: 85,
format: nil, # Preserve original
applies_to: "image",
enabled: true,
order: 3
}
"""
use Ecto.Schema
use PhoenixKit.SchemaPrefix
import Ecto.Changeset
alias PhoenixKit.Modules.Storage.DimensionOptions
@primary_key {:uuid, UUIDv7, autogenerate: true}
@foreign_key_type UUIDv7
# The sizes every variant set has (V205): a size's name is part of every
# file URL, and core and modules ask for these by name. The first three
# of `@aspect_slots` feed grids and justified layouts, so they keep the
# aspect ratio; only `thumbnail` may be cropped.
@crop_modes ~w(center focus)
@shapes ~w(any wide tall)
@fit_sides ~w(width height)
@standard_slots ~w(thumbnail small medium large video_thumbnail)
@aspect_slots ~w(small medium large)
@type t :: %__MODULE__{
uuid: UUIDv7.t() | nil,
name: String.t() | nil,
width: integer() | nil,
height: integer() | nil,
quality: integer() | nil,
format: String.t() | nil,
applies_to: String.t() | nil,
enabled: boolean(),
maintain_aspect_ratio: boolean(),
options: PhoenixKit.Modules.Storage.DimensionOptions.t(),
alternative_formats: [String.t()],
order: integer(),
variant_set_uuid: UUIDv7.t() | nil,
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}
schema "phoenix_kit_storage_dimensions" do
field :name, :string
field :width, :integer
field :height, :integer
field :quality, :integer
field :format, :string
field :applies_to, :string
field :enabled, :boolean, default: true
field :maintain_aspect_ratio, :boolean, default: true
# The rendition's options (V217): how a fixed box is cropped, which side a
# proportional one fixes, who it is made for, whether it keeps an HDR gain map.
# One JSON object with a typed struct (`DimensionOptions`); `shape` and the
# others are not part of the spec hash, as they were not as columns: they decide
# whether a file gets the size, not what the size looks like.
embeds_one :options, PhoenixKit.Modules.Storage.DimensionOptions,
on_replace: :update,
defaults_to_struct: true
field :alternative_formats, {:array, :string}, default: []
field :order, :integer, default: 0
# The variant set the size belongs to (V205). The column defaults to the
# Default set, so a writer that names none lands there;
# `read_after_writes` reads the default back.
field :variant_set_uuid, UUIDv7, read_after_writes: true
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating or updating a dimension.
## Required Fields
- `name`
- `applies_to`
- `width` - Required when `maintain_aspect_ratio` is true or false
## Optional Fields
- `height` - Required when `maintain_aspect_ratio` is false (fixed dimensions)
- `maintain_aspect_ratio` - Whether to preserve aspect ratio (default: true)
## Validation Rules
- Name must be unique
- Width must be specified (always required)
- Height must be specified when `maintain_aspect_ratio` is false
- Width and height must be positive integers
- Quality must be between 1-100 for images or 0-51 for videos
- Applies to must be one of: "image", "video", "both"
- Format must be valid if specified
- Order must be >= 0
"""
def changeset(dimension, attrs) do
dimension
|> cast(fold_options(attrs), [
:name,
:width,
:height,
:quality,
:format,
:applies_to,
:enabled,
:maintain_aspect_ratio,
:alternative_formats,
:order
])
|> cast_embed(:options, with: &DimensionOptions.changeset/2)
|> validate_required([:name, :applies_to])
|> validate_format(:name, ~r/^[a-z0-9_]+$/,
message: "must contain only lowercase letters, numbers, and underscores"
)
|> validate_length(:name, min: 1, max: 50)
|> validate_inclusion(:applies_to, ["image", "video", "both"])
|> surface_option_errors()
|> validate_number(:width, greater_than: 0)
|> validate_number(:height, greater_than: 0)
|> validate_number(:order, greater_than_or_equal_to: 0)
|> validate_dimension_size()
|> clear_width_when_height_fixed()
|> validate_quality()
|> validate_format()
|> validate_alternative_formats()
|> validate_standard_slot()
|> validate_standard_fit_side()
|> validate_standard_shape()
|> unique_constraint(:name, name: :phoenix_kit_storage_dimensions_name_index)
end
# The options a form or a caller names at the top level (`crop_mode: "focus"`,
# `"keep_hdr" => "true"`) go into `options`, whichever kind of key the map uses
# (Ecto allows one kind per map). A map that already has `options` keeps them and
# the top-level names win.
defp fold_options(attrs) when is_map(attrs) do
names = DimensionOptions.keys()
keys = Enum.flat_map(names, &[&1, Atom.to_string(&1)])
{flat, rest} = Map.split(attrs, keys)
if flat == %{} do
attrs
else
string_keys? = Enum.any?(Map.keys(rest) ++ Map.keys(flat), &is_binary/1)
existing = Map.get(rest, :options) || Map.get(rest, "options") || %{}
merged =
Map.new(existing, fn {k, v} -> {to_string(k), v} end)
|> Map.merge(Map.new(flat, fn {k, v} -> {to_string(k), v} end))
rest
|> Map.drop([:options, "options"])
|> Map.put(if(string_keys?, do: "options", else: :options), merged)
end
end
defp fold_options(attrs), do: attrs
# An option's error shows under its own name in the editor (`render_error(cs, :shape)`),
# as it did when each was a column of its own.
defp surface_option_errors(%Ecto.Changeset{changes: %{options: %Ecto.Changeset{} = opts}} = cs) do
Enum.reduce(opts.errors, cs, fn {key, {message, meta}}, acc ->
add_error(acc, key, message, meta)
end)
end
defp surface_option_errors(cs), do: cs
# An option as the changeset has it: the changed value, else the stored one.
defp option(changeset, key) do
case get_field(changeset, :options) do
%DimensionOptions{} = options -> Map.get(options, key)
_ -> Map.get(%DimensionOptions{}, key)
end
end
# Whether the form changed `key` (or the size is new, so everything is set).
defp option_changed?(changeset, key) do
case changeset.changes do
%{options: %Ecto.Changeset{changes: changes}} -> Map.has_key?(changes, key)
_ -> false
end
end
@doc """
A `%Dimension{}` built from a keyword list or map whose keys may include the
options (`crop_mode`, `fit_by`, `shape`, `keep_hdr`) at the top level, as the
changeset takes them. For code and tests that need a size without a database.
"""
@spec new(Enumerable.t()) :: t()
def new(attrs \\ []) do
{options, rest} = attrs |> Map.new() |> Map.split(DimensionOptions.keys())
struct!(__MODULE__, Map.put(rest, :options, struct!(DimensionOptions, options)))
end
@doc "How a rendition is cropped when it is a fixed box: `center` or `focus`."
def crop_mode(%__MODULE__{options: %DimensionOptions{crop_mode: mode}}), do: mode
def crop_mode(_), do: "center"
@doc "The side a rendition that keeps proportions fixes: `width` or `height`."
def fit_by(%__MODULE__{options: %DimensionOptions{fit_by: side}}), do: side
def fit_by(_), do: "width"
@doc "Which pictures the rendition is made for: `any`, `wide` or `tall`."
def shape(%__MODULE__{options: %DimensionOptions{shape: shape}}), do: shape
def shape(_), do: "any"
@doc "Whether the rendition of an HDR photo keeps the gain map (when it can)."
def keep_hdr?(%__MODULE__{options: %DimensionOptions{keep_hdr: keep}}), do: keep == true
def keep_hdr?(_), do: false
@doc "Whether the rendition leaves the camera's metadata out (on unless a size says otherwise)."
def strip_metadata?(%__MODULE__{options: %DimensionOptions{strip_metadata: strip}}),
do: strip != false
def strip_metadata?(_), do: true
@doc "Whether an animated GIF or WebP stays animated in this rendition (on unless a size says otherwise)."
def keep_animation?(%__MODULE__{options: %DimensionOptions{keep_animation: keep}}),
do: keep != false
def keep_animation?(_), do: true
@doc "How a fixed rendition can be cropped: around the middle, or around the subject."
def crop_modes, do: @crop_modes
@doc """
Whether `dimension` is cropped around the photo's focal point: a fixed box
(not one that keeps proportions) whose crop mode is `focus`.
"""
def focus_crop?(%__MODULE__{maintain_aspect_ratio: false} = dimension),
do: crop_mode(dimension) == "focus"
def focus_crop?(_dimension), do: false
@doc "Which pictures a rendition can be made for: every one, the wide ones, the tall ones."
def shapes, do: @shapes
@doc "The side a rendition that keeps proportions can fix: its width, or its height."
def fit_sides, do: @fit_sides
@doc """
Whether `dimension` keeps proportions and fixes its **height** (the width
follows each photo: a horizontal panorama's thumbnail).
"""
def fixed_height?(%__MODULE__{maintain_aspect_ratio: true} = dimension),
do: fit_by(dimension) == "height"
def fixed_height?(_dimension), do: false
@doc "The sizes every variant set has; they cannot be renamed or deleted."
def standard_slots, do: @standard_slots
@doc "The standard sizes that always keep the aspect ratio."
def aspect_slots, do: @aspect_slots
@doc "Whether `dimension` is one of the standard sizes."
def standard_slot?(%__MODULE__{name: name}), do: name in @standard_slots
# A standard size keeps its name, and small/medium/large keep the aspect
# ratio, whatever else an admin changes about them.
defp validate_standard_slot(changeset) do
old_name = changeset.data.name
name = get_field(changeset, :name)
cond do
old_name in @standard_slots and name != old_name ->
add_error(changeset, :name, "is a standard size and cannot be renamed")
# Checked when the size is made, renamed, or its aspect setting is
# changed: an install whose `small` was cropped before V205 can still
# switch it off or change its quality without fixing that first.
name in @aspect_slots and get_field(changeset, :maintain_aspect_ratio) == false and
(is_nil(changeset.data.uuid) or Map.has_key?(changeset.changes, :name) or
Map.has_key?(changeset.changes, :maintain_aspect_ratio)) ->
add_error(
changeset,
:maintain_aspect_ratio,
"must stay on for %{name}: only thumbnail may be cropped",
name: name
)
true ->
changeset
end
end
# A rendition that keeps proportions and fixes its height has no width: it follows
# each photo, and a width left over from before would make the rendition look
# like a width-picked one (`variant_for/2`).
defp clear_width_when_height_fixed(changeset) do
if get_field(changeset, :maintain_aspect_ratio) != false and
option(changeset, :fit_by) == "height",
do: put_change(changeset, :width, nil),
else: changeset
end
# The standard sizes are picked by width (`variant_for/2`, the stand-in of a size
# not made yet), so they keep fixing it. Checked when the size is made, renamed,
# or its fixed side is changed.
defp validate_standard_fit_side(changeset) do
name = get_field(changeset, :name)
if name in @standard_slots and option(changeset, :fit_by) == "height" and
(is_nil(changeset.data.uuid) or Map.has_key?(changeset.changes, :name) or
option_changed?(changeset, :fit_by)) do
add_error(
changeset,
:fit_by,
"must stay on width for %{name}: the standard sizes are picked by width",
name: name
)
else
changeset
end
end
# The standard sizes are asked for by name for every picture, so none of them
# may be limited to wide or tall ones.
defp validate_standard_shape(changeset) do
name = get_field(changeset, :name)
if name in @standard_slots and option(changeset, :shape) != "any" and
(is_nil(changeset.data.uuid) or Map.has_key?(changeset.changes, :name) or
option_changed?(changeset, :shape)) do
add_error(
changeset,
:shape,
"must stay on any picture for %{name}: the standard sizes are made for every one",
name: name
)
else
changeset
end
end
# Validate dimensions based on maintain_aspect_ratio setting
defp validate_dimension_size(changeset) do
width = get_field(changeset, :width)
height = get_field(changeset, :height)
maintain_aspect = get_field(changeset, :maintain_aspect_ratio)
fit_by = option(changeset, :fit_by)
cond do
# Keeping proportions with a fixed height: the height is the size, and the
# width is left to follow each photo.
maintain_aspect != false and fit_by == "height" and is_nil(height) ->
add_error(changeset, :height, "height is required when the height is fixed")
maintain_aspect != false and fit_by == "height" ->
changeset
is_nil(width) ->
add_error(changeset, :width, "width is required")
maintain_aspect == false && is_nil(height) ->
add_error(changeset, :height, "height is required when not maintaining aspect ratio")
true ->
changeset
end
end
# Validate quality based on file type
defp validate_quality(changeset) do
quality = get_field(changeset, :quality)
applies_to = get_field(changeset, :applies_to)
format = get_field(changeset, :format)
cond do
is_nil(quality) ->
changeset
# A video rendition that is a still frame (the video thumbnail) outputs an
# image, so it uses the image quality scale (1-100), whatever its name
applies_to == "video" and format in ["jpg", "jpeg", "png", "webp", "gif"] ->
validate_number(changeset, :quality,
greater_than_or_equal_to: 1,
less_than_or_equal_to: 100
)
applies_to in ["image", "both"] ->
validate_number(changeset, :quality,
greater_than_or_equal_to: 1,
less_than_or_equal_to: 100
)
applies_to == "video" ->
validate_number(changeset, :quality,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 51
)
true ->
changeset
end
end
# Validate format is supported
defp validate_format(changeset) do
format = get_field(changeset, :format)
applies_to = get_field(changeset, :applies_to)
if is_nil(format) do
changeset
else
valid_formats = get_valid_formats(applies_to)
if format in valid_formats do
changeset
else
add_error(changeset, :format, "must be one of: #{Enum.join(valid_formats, ", ")}")
end
end
end
# Validate alternative_formats: must be valid image formats, no duplicates, exclude primary format
defp validate_alternative_formats(changeset) do
alt_formats = get_field(changeset, :alternative_formats) || []
primary_format = get_field(changeset, :format)
applies_to = get_field(changeset, :applies_to)
# Filter out empty strings (from hidden checkbox input)
alt_formats = Enum.reject(alt_formats, &(&1 == ""))
changeset = put_change(changeset, :alternative_formats, alt_formats)
cond do
alt_formats == [] ->
changeset
applies_to == "video" ->
add_error(changeset, :alternative_formats, "not supported for video-only dimensions")
true ->
valid = get_valid_image_formats()
invalid = Enum.reject(alt_formats, &(&1 in valid))
has_primary = primary_format && primary_format in alt_formats
has_dupes = length(alt_formats) != length(Enum.uniq(alt_formats))
changeset
|> then(fn cs ->
if invalid != [],
do:
add_error(cs, :alternative_formats, "invalid formats: #{Enum.join(invalid, ", ")}"),
else: cs
end)
|> then(fn cs ->
if has_primary,
do: add_error(cs, :alternative_formats, "must not include the primary format"),
else: cs
end)
|> then(fn cs ->
if has_dupes,
do: add_error(cs, :alternative_formats, "must not contain duplicates"),
else: cs
end)
end
end
defp get_valid_image_formats, do: ["jpg", "jpeg", "png", "webp", "avif", "gif"]
defp get_valid_formats("image"), do: ["jpg", "jpeg", "png", "webp", "gif"]
# Videos can output video formats, but video_thumbnail outputs JPG images
defp get_valid_formats("video"), do: ["mp4", "webm", "avi", "mov", "jpg", "jpeg", "png", "webp"]
defp get_valid_formats("both"),
do: ["jpg", "jpeg", "png", "webp", "gif", "mp4", "webm", "avi", "mov"]
@doc """
Returns whether this dimension applies to images.
"""
def applies_to_images?(%__MODULE__{applies_to: applies_to})
when applies_to in ["image", "both"],
do: true
def applies_to_images?(_), do: false
@doc """
Returns whether this dimension applies to videos.
"""
def applies_to_videos?(%__MODULE__{applies_to: applies_to})
when applies_to in ["video", "both"],
do: true
def applies_to_videos?(_), do: false
@doc """
Returns whether this dimension preserves aspect ratio.
"""
def preserve_aspect_ratio?(%__MODULE__{maintain_aspect_ratio: maintain_aspect}) do
maintain_aspect == true
end
@doc """
Returns a human-readable description of this dimension.
"""
def description(%__MODULE__{
name: name,
width: width,
height: height,
format: format,
maintain_aspect_ratio: maintain_aspect
}) do
size_desc =
cond do
maintain_aspect && width -> "#{width}px wide (aspect ratio maintained)"
width && height -> "#{width}x#{height}"
width -> "#{width}px wide"
height -> "#{height}px tall"
true -> "auto"
end
format_desc = if format, do: " (#{format})", else: ""
"#{name}: #{size_desc}#{format_desc}"
end
end