lib/image/social.ex

defmodule Image.Social do
  @moduledoc """
  Functions to introspect standard image sizes for
  various social media platforms.

  """

  alias Vix.Vips.Image, as: Vimage

  @typedoc """
  The social media platform image usage.

  """
  @type image_usage :: atom()

  @typedoc """
  The known social media platforms.

  """
  @type platform ::
          :facebook
          | :twitter
          | :linkedin
          | :pinterest
          | :instagram
          | :tumblr
          | :youtube
          | :snapchat
          | :tiktok

  # Before Version 1.0, this content will move from being a static
  # list to a dynamic one that can be updated at runtime

  # Facebook: https://www.facebook.com/help/125379114252045?helpref=faq_content
  # https://www.socialpilot.co/blog/social-media-image-sizes
  # https://blog.hootsuite.com/social-media-image-sizes-guide/

  @social_sizes %{
    facebook: %{
      default: :post,
      profile: "170x170",
      cover_desktop: "820x312",
      cover_mobile: "640x360",
      post: "1200x630",
      banner: "1200x630",
      story: "1080x1920"
    },
    twitter: %{
      profile: "400x400",
      cover: "1500x500",
      shared: "900x450",
      stream: "440x220"
    },
    linkedin: %{
      profile: "400x400",
      cover: "1584x396",
      company_logo: "400x400",
      blog: "1200x627"
    },
    pinterest: %{
      profile: "165x165",
      cover: "800x450",
      pin: "1080x1920",
      board: "222x150"
    },
    instagram: %{
      profile: "320x320",
      cover: "1080x1920",
      photo: %{
        landscape: "1080x596",
        portrait: "1080x1350",
        square: "1080x1080"
      },
      thumb: "161x161",
      story: "1080x1920",
      carousel: %{
        landscape: "1080x566",
        portrait: "1080x1350",
        square: "1080x1080"
      }
    },
    tumblr: %{
      profile: "128x128",
      banner: "3000x1055",
      post: "500x750"
    },
    youtube: %{
      cover: "2560x1440",
      profile: "800x800",
      banner: "2048x1152",
      thumbnail: "1280x720"
    },
    snapchat: %{
      photo: "1080x1920",
      share: "750x1334"
    },
    tiktok: %{
      profile: "20x20",
      video: "1080x1920"
    }
  }

  @social_platforms Map.keys(@social_sizes)

  @doc """
  Return the map of social media image
  sizes.

  The returned map of maps is of a standard form:

  * The first key is the platform name (ie `:twitter`)
  * The second key is the image type for the platform (ie `:profile`)
  * The third level is optional. But if it exists it must
    have three keys: `:landscape`, `:portrait` and `:square`

  The values are all of the form "WxH" where `W` is the
  width in pixels and `H` is the height in pixels.

  """
  def media_sizes do
    @social_sizes
  end

  @doc """
  Returns a list of known social
  platforms.

  ### Example


  """
  @spec known_platforms :: [platform()]
  def known_platforms do
    @social_platforms
  end

  @doc """
  Returns the image types for the given
  social platform.

  ### Arguments

  * `platform` is any known social platform.
    See `Image.Social.known_platforms/0`.

  ### Returns

  * A list of images uses available for the
    platform.

  ### Example


  """
  @spec image_usages(platform()) :: [image_usage()]
  def image_usages(platform) when platform in @social_platforms do
    media_sizes()
    |> Map.fetch!(platform)
    |> Map.delete(:default)
    |> Map.keys()
  end

  @doc """
  Returns the default image type that an
  image is resized to for a given platform
  is no `:type` parameter is provided.

  ### Arguments

  * `platform` is any known social platform.
    See `Image.Social.known_platforms/0`.

  ### Returns

  * The default image type for the platform.

  ### Example

  """
  @spec default_image_usage(platform()) :: image_usage()
  def default_image_usage(platform) when platform in @social_platforms do
    media_sizes()
    |> Map.fetch!(platform)
    |> Map.get(:default)
  end

  @doc """
  Resize an image for a particular social
  platform and usage.

  This function:

  * Resizes an image to the correct dimensions, including being
    image aspect aware
  * Converts to the sRGB color space
  * Minimises metadata (retains only Artist and Copyright)

  ### Arguments

  * `image` is any `t:Vix.Vips.Image.t/0`

  * `platform` is the name of a known social
    media platform. See `Image.Social.known_platforms/0`.

  * `options` is a keyword list of options.

  ### Options

  * `:type` is the image type within the social
    platform for which the image should be resized. See
    `Image.Social.image_usages/1`

  * All other options are passed to `Image.thumbnail!/3`.

  ### Returns

  * `{:ok, resized_image}` or

  * `{:error, reason}`

  """
  @spec resize(Vimage.t(), platform(), Keyword.t()) ::
          {:ok, Vimage.t()} | {:error, Image.error_message()}

  def resize(image, platform, options \\ [])

  def resize(%Vimage{} = image, platform, options) when platform in @social_platforms do
    {usage, options} = Keyword.pop(options, :usage, :default)
    options = Keyword.put_new(options, :crop, :attention)
    aspect = Image.aspect(image)
    platform = Map.fetch!(media_sizes(), platform)

    with {:ok, size} <- get_image_size(platform, usage, aspect) do
      image
      |> Image.thumbnail!(size, options)
      |> Image.to_colorspace!(:srgb)
      |> Image.minimize_metadata()
    end
  end

  def resize(%Vimage{} = _image, platform, _options) do
    {:error, unknown_platform_error(platform)}
  end

  @doc """
  Resize an image for a particular social
  platform and usage.

  This function:

  * Resizes an image to the correct dimensions, including being
    image orientation aware
  * Converts to the sRGB color space
  * Minimises metadata (retains only Artist and Copyright)

  ### Arguments

  * `image` is any `t:Vix.Vips.Image.t/0`

  * `platform` is the name of a known social
    media platform. See `Image.Social.known_platforms/0`.

  * `options` is a keyword list of options.

  ### Options

  * `:type` is the image type within the social
    platform for which the image should be resized. See
    `Image.Social.image_usages/1`

  * All other options are passed to `Image.thumbnail!/3`.

  ## Returns

  * `resized_image` or

  * Raises an exception.

  """
  @spec resize!(Vimage.t(), platform(), Keyword.t()) ::
          Vimage.t() | no_return

  def resize!(%Vimage{} = image, platform, options \\ []) do
    case resize(image, platform, options) do
      {:ok, image} -> image
      {:error, reason} -> raise Image.Error, reason
    end
  end

  # ---- Helpers -----

  defp get_image_size(platform, :default = type, orientation) do
    default = Map.get(platform, type, {nil, type})
    get_image_size(platform, default, orientation)
  end

  defp get_image_size(_platform, {nil, usage}, _orientation) do
    {:error, unknown_usage_error(usage)}
  end

  defp get_image_size(platform, usage, orientation) when is_map_key(platform, usage) do
    platform
    |> Map.get(usage)
    |> resolve_orientation(orientation)
  end

  defp get_image_size(_platform, usage, _orientation) do
    {:error, unknown_usage_error(usage)}
  end

  defp resolve_orientation(size, _orientation) when is_binary(size) do
    {:ok, size}
  end

  defp resolve_orientation(sizes, orientation) when is_map_key(sizes, orientation) do
    {:ok, Map.fetch!(sizes, orientation)}
  end

  defp resolve_orientation(_sizes, orientation) do
    {:error, unknown_orientation_error(orientation)}
  end

  defp unknown_platform_error(platform) do
    "Unknown social platform #{inspect(platform)}"
  end

  defp unknown_usage_error(:default) do
    "No :default usage is configured"
  end

  defp unknown_usage_error(usage) do
    "Unknown image usage #{inspect(usage)}"
  end

  defp unknown_orientation_error(orientation) do
    "Unknown orientation #{inspect(orientation)}"
  end
end