Packages

phoenix_kit

2.64.1
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
phoenix_kit lib modules storage tile_source.ex
Raw

lib/modules/storage/tile_source.ex

defmodule PhoenixKit.Modules.Storage.TileSource do
  @moduledoc """
  The image deep-zoom tiles are cut from.

  Tiles are cut by libvips (Tessera) from the file's original. libvips as shipped
  with the `vix` package can read an iPhone's HEIC for its size but has no HEVC
  decoder, so it cannot read a single pixel of it: without help a HEIC photo has no
  tiles, and the viewer stops at the largest rendition (1920 px) where a JPEG
  zooms to the last pixel. ImageMagick decodes HEIC, HEIF and AVIF.

  For those types the first request for a tile makes **one full-resolution JPEG**
  of the original with ImageMagick (`ImageProcessor.full_jpeg/3`), stores it
  beside the tiles under `_tiles/<base>_source.jpg` (a system-managed file of the
  original, so it goes with it), and every tile is cut from it. The key carries the
  original's version, so an edited original makes a new one. Any other original is
  used as it is. The caller holds the file's tile lock, so concurrent requests make
  the JPEG once.
  """

  require Logger

  alias PhoenixKit.Modules.Storage.{ImageProcessor, Manager, TesseraAdapter}

  @convert_mimes ~w(image/heic image/heif image/avif)

  @type prepared :: {:ok, Path.t(), (-> :ok)} | {:error, term()}

  @doc "Whether tiles of this original have to be cut from a converted copy."
  @spec convert?(map() | nil) :: boolean()
  def convert?(%{mime_type: mime}) when is_binary(mime),
    do: String.downcase(mime) in @convert_mimes

  def convert?(_instance), do: false

  @doc "The key, under the tiles prefix, of the converted copy of the original `base` names."
  @spec key(String.t()) :: String.t()
  def key(base), do: base <> "_source.jpg"

  @doc """
  A local path to an image the tiles can be cut from, and a function that removes
  whatever temporary files were made for it. `source` is the tile source map
  (`:base`, `:file_uuid`, `:width`, `:height`), `instance` the original's file
  instance.
  """
  @spec prepare(map(), map()) :: prepared()
  def prepare(source, instance) do
    if convert?(instance), do: converted(source, instance), else: original(instance)
  end

  defp original(instance) do
    path = temp("tessera-src")

    case Manager.retrieve_file(instance.file_name, destination_path: path) do
      {:ok, _} -> {:ok, path, remover([path])}
      {:error, _} = error -> discard(path, error)
    end
  end

  defp converted(source, instance) do
    key = key(source.base)
    destination = TesseraAdapter.destination_for(key)
    jpeg = temp("tessera-jpeg") <> ".jpg"

    if Manager.file_exists?(destination) do
      case Manager.retrieve_file(destination, destination_path: jpeg) do
        {:ok, _} -> {:ok, jpeg, remover([jpeg])}
        {:error, _} = error -> discard(jpeg, error)
      end
    else
      build(source, instance, key, jpeg)
    end
  end

  defp build(source, instance, key, jpeg) do
    original = temp("tessera-heic")

    result =
      with {:ok, _} <- Manager.retrieve_file(instance.file_name, destination_path: original),
           {:ok, _} <- ImageProcessor.full_jpeg(original, jpeg),
           :ok <- same_size(jpeg, source) do
        store(jpeg, key, source)
        {:ok, jpeg, remover([jpeg])}
      end

    File.rm(original)

    case result do
      {:ok, _, _} = ok ->
        ok

      {:error, reason} = error ->
        File.rm(jpeg)
        Logger.warning("[TileSource] no JPEG made of #{source.file_uuid}: #{inspect(reason)}")
        error
    end
  end

  # Tiles are cut by the file's recorded size; a copy of another size would put
  # every tile in the wrong place, so it is refused rather than used.
  defp same_size(jpeg, %{width: width, height: height}) do
    case ImageProcessor.extract_dimensions(jpeg) do
      {:ok, {^width, ^height}} -> :ok
      {:ok, {w, h}} -> {:error, {:dimension_mismatch, {w, h}, {width, height}}}
      {:error, _} = error -> error
    end
  end

  # Kept for the next tile. A copy that could not be kept is still used for this
  # one; the next request makes it again.
  defp store(jpeg, key, source) do
    case TesseraAdapter.put(jpeg, key,
           parent_file_uuid: source.file_uuid,
           mime_type: "image/jpeg",
           metadata: %{"kind" => "tile_source"}
         ) do
      :ok ->
        :ok

      {:error, reason} ->
        Logger.warning(
          "[TileSource] could not keep the JPEG of #{source.file_uuid}: #{inspect(reason)}"
        )
    end
  end

  defp discard(path, error) do
    File.rm(path)
    error
  end

  defp remover(paths), do: fn -> Enum.each(paths, &File.rm/1) end

  defp temp(prefix),
    do: Path.join(System.tmp_dir!(), "#{prefix}-#{System.unique_integer([:positive])}")
end