Packages
phoenix_kit
2.56.0
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/focal_point.ex
defmodule PhoenixKit.Modules.Storage.FocalPoint do
@moduledoc """
Where the subject of a photo is, so a rendition can crop around it.
A **focal point** is `{x, y}`, fractions of the photo **as it is displayed**
(after its EXIF orientation), `0.0..1.0` from the left and from the top. It is
kept in the file's `metadata` JSON (`"focal"`: `x`, `y` and `source`), so it
needs no column. A fixed rendition whose crop mode is `focus` (V210) crops a
window of its shape centered on it (`ImageProcessor.resize_and_crop_focus/6`);
every such rendition of the photo (a square thumbnail, a 4:3, a 16:9) uses the
same point, and a photo with none is cropped at the center.
## Where it comes from
* `"manual"`: set by a person (`put/4`); wins over everything and is never
replaced by detection.
* `"auto"`: found by `detect/1`, which asks libvips where the photo draws the
eye (`smartcrop` with `attention`: edges, colour, skin tone) on a copy shrunk to
#{512}px, which costs a few milliseconds. It needs the optional `vix`
package; without it, or when detection fails, there is no point and the crop
stays at the center.
`ensure/2` is what the generator calls the first time a photo needs one: the
stored point, else a detected one (stored for next time), else `nil`.
"""
import Ecto.Query, only: [from: 2]
alias PhoenixKit.Modules.Storage.File, as: StorageFile
alias PhoenixKit.Modules.Storage.ImageProcessor
alias PhoenixKit.RepoHelper
require Logger
# The long side of the copy detection looks at.
@shrink_to 512
# The same ceiling the ImageMagick calls keep: no decoding a decompression bomb.
@max_pixels 40_000_000
@sources ~w(auto manual)
@type point :: {float(), float()}
@doc "Whether detection is available (the optional `vix` package is loaded)."
@spec detection_available?() :: boolean()
def detection_available?, do: Code.ensure_loaded?(Vix.Vips.Operation)
@doc """
The focal point stored on `file` and where it came from, or `nil`:
`{{x, y}, "auto" | "manual"}`.
"""
@spec get(map()) :: {point(), String.t()} | nil
def get(%{metadata: metadata}), do: from_metadata(metadata)
def get(_file), do: nil
defp from_metadata(%{"focal" => %{"x" => x, "y" => y} = focal})
when is_number(x) and is_number(y) and x >= 0 and x <= 1 and y >= 0 and y <= 1 do
{{x / 1, y / 1}, Map.get(focal, "source", "auto")}
end
defp from_metadata(_metadata), do: nil
@doc """
Records a focal point for `file`: `x` and `y` between 0 and 1, `source` `"auto"`
or `"manual"`. A detected point never replaces a manual one (`{:ok, :kept}`).
Only the `"focal"` key of the metadata changes.
"""
@spec put(map(), number(), number(), String.t()) ::
{:ok, point()} | {:ok, :kept} | {:error, :invalid}
def put(%{uuid: uuid}, x, y, source)
when is_number(x) and is_number(y) and x >= 0 and x <= 1 and y >= 0 and y <= 1 and
source in @sources do
focal = %{"x" => Float.round(x / 1, 4), "y" => Float.round(y / 1, 4), "source" => source}
# `source = auto` writes only where no manual point is: one statement, so a
# person's choice made while detection runs is not lost.
query =
if source == "manual" do
from(f in StorageFile, where: f.uuid == ^uuid)
else
from(f in StorageFile,
where:
f.uuid == ^uuid and
fragment("coalesce(?->'focal'->>'source', '') <> 'manual'", f.metadata)
)
end
{count, _} =
from(f in query,
update: [
set: [
metadata:
fragment(
"coalesce(?, '{}'::jsonb) || ?",
f.metadata,
type(^%{"focal" => focal}, :map)
)
]
]
)
|> RepoHelper.repo().update_all([])
if count == 1, do: {:ok, {focal["x"], focal["y"]}}, else: {:ok, :kept}
end
def put(_file, _x, _y, _source), do: {:error, :invalid}
@doc "Forgets the focal point of `file`: renditions crop at the center again."
@spec clear(map()) :: :ok
def clear(%{uuid: uuid}) do
from(f in StorageFile,
where: f.uuid == ^uuid,
update: [set: [metadata: fragment("coalesce(?, '{}'::jsonb) - 'focal'", f.metadata)]]
)
|> RepoHelper.repo().update_all([])
:ok
end
@doc """
The focal point to crop `file` around, from its original at `local_path`: the
stored one, else a detected one (stored, so the next rendition does not detect
again), else `nil`. Never raises: a photo detection cannot read is cropped at
the center.
"""
@spec ensure(map(), Path.t()) :: point() | nil
def ensure(%{uuid: uuid} = file, local_path) do
# Read again: a person may have set one since `file` was loaded.
stored =
RepoHelper.repo().one(from(f in StorageFile, where: f.uuid == ^uuid, select: f.metadata))
case from_metadata(stored) do
{point, _source} -> point
nil -> detect_and_store(file, local_path)
end
rescue
error ->
Logger.warning("FocalPoint.ensure failed: #{Exception.message(error)}")
nil
catch
:exit, reason ->
Logger.warning("FocalPoint.ensure failed: #{inspect(reason)}")
nil
end
defp detect_and_store(file, local_path) do
case detect(local_path) do
{:ok, {x, y}} ->
case put(file, x, y, "auto") do
{:ok, {_x, _y} = point} -> point
# A manual point appeared meanwhile.
{:ok, :kept} -> get_stored(file)
_ -> {x, y}
end
:error ->
nil
end
end
defp get_stored(%{uuid: uuid}) do
stored =
RepoHelper.repo().one(from(f in StorageFile, where: f.uuid == ^uuid, select: f.metadata))
case from_metadata(stored) do
{point, _source} -> point
nil -> nil
end
end
@doc """
Where the photo at `path` draws the eye, as `{:ok, {x, y}}` (fractions of the
displayed photo), or `:error` when detection is unavailable or cannot read it.
"""
@spec detect(Path.t()) :: {:ok, point()} | :error
def detect(path) do
if detection_available?(), do: run_detection(path), else: :error
end
defp run_detection(path) do
with {:ok, {w, h}} <- ImageProcessor.extract_dimensions(path),
true <- w * h <= @max_pixels do
# A NIF call: bounded in time, and a failure is "no focal point".
task = Task.async(fn -> attention(path) end)
case Task.yield(task, 10_000) || Task.shutdown(task, :brutal_kill) do
{:ok, result} -> result
_timeout_or_exit -> :error
end
else
_ -> :error
end
rescue
_ -> :error
catch
_, _ -> :error
end
# Shrink on load (which also applies the EXIF orientation), then ask where the
# attention is. The coordinates are on the shrunk copy, so they are divided by
# its size.
defp attention(path) do
alias Vix.Vips.{Image, Operation}
with {:ok, small} <- Operation.thumbnail(path, @shrink_to, size: :VIPS_SIZE_DOWN),
width = Image.width(small),
height = Image.height(small),
true <- width > 1 and height > 1,
{:ok, {_cropped, found}} <-
Operation.smartcrop(small, max(div(width, 2), 1), max(div(height, 2), 1),
interesting: :VIPS_INTERESTING_ATTENTION
) do
found = Map.new(found)
x = found[:"attention-x"]
y = found[:"attention-y"]
cond do
not (is_number(x) and is_number(y)) -> :error
# A photo with nothing that draws the eye (flat, or blank) is reported at
# the corner: that is "nothing found", not a subject in the corner.
x == 0 and y == 0 -> :error
true -> {:ok, {clamp(x / width), clamp(y / height)}}
end
else
_ -> :error
end
end
defp clamp(value), do: value |> max(0.0) |> min(1.0) |> Kernel./(1)
end