defmodule PhilomenaMedia.GifPreview do
  @moduledoc """
  GIF preview generation for video files.
  """

  @type duration :: float()
  @type dimensions :: {pos_integer(), pos_integer()}

  @type num_images :: integer()
  @type target_framerate :: 1..50
  @type opts :: [
          num_images: num_images(),
          target_framerate: target_framerate()
        ]

  @typedoc "One of av1, h264, libvpx, libvpx-vp9"
  @type decoder :: String.t()

  @doc """
  Generate a GIF preview of the given video input with evenly-spaced sample points.

  The input should have pre-computed duration `duration`. The `dimensions`
  are a `{target_width, target_height}` tuple.

  Depending on the input file, this may take a long time to process.

  Options:
  - `:target_framerate` - framerate of the output GIF, must be between 1 and 50.
    Default 2.
  - `:num_images` - number of images to sample from the video.
    Default is determined by the duration:
    * 90 or above: 20 images
    * 30 or above: 10 images
    * 1 or above: 5 images
    * otherwise: 2 images
  """
  @spec preview(decoder(), Path.t(), Path.t(), duration(), dimensions(), opts()) :: :ok
  def preview(decoder, video, gif, duration, dimensions, opts \\ []) do
    target_framerate = Keyword.get(opts, :target_framerate, 2)

    num_images =
      Keyword.get_lazy(opts, :num_images, fn ->
        cond do
          duration >= 90 -> 20
          duration >= 30 -> 10
          duration >= 1 -> 5
          true -> 2
        end
      end)

    {_output, 0} =
      System.cmd(
        "ffmpeg",
        commands(decoder, video, gif, clamp(duration), dimensions, num_images, target_framerate)
      )

    :ok
  end

  @spec commands(
          decoder(),
          Path.t(),
          Path.t(),
          duration(),
          dimensions(),
          num_images(),
          target_framerate()
        ) ::
          [String.t()]
  defp commands(
         decoder,
         video,
         gif,
         duration,
         {target_width, target_height},
         num_images,
         target_framerate
       ) do
    # Compute range [0, num_images)
    image_range = 0..(num_images - 1)

    # Generate input list in the following form:
    #   -ss 0.0 -c:v libvpx -i input.webm
    input_arguments =
      Enum.flat_map(
        image_range,
        &["-ss", "#{&1 * duration / num_images}", "-c:v", decoder, "-i", video]
      )

    # Generate graph in the following form:
    #   [0:v] trim=end_frame=1 [t0]; [1:v] trim=end_frame=1 [t1] ...
    trim_filters =
      Enum.map_join(image_range, ";", &"[#{&1}:v] trim=end_frame=1 [t#{&1}]")

    # Generate graph in the following form:
    #   [t0][t1]... concat=n=10 [concat]
    concat_input_pads =
      Enum.map_join(image_range, "", &"[t#{&1}]")

    concat_filter =
      "#{concat_input_pads} concat=n=#{num_images}, settb=1/#{target_framerate}, setpts=N [concat]"

    scale_filter =
      "[concat] scale=width=#{target_width}:height=#{target_height},setsar=1 [scale]"

    split_filter = "[scale] split [s0][s1]"

    palettegen_filter =
      "[s0] palettegen=stats_mode=single:max_colors=255:reserve_transparent=1 [palettegen]"

    paletteuse_filter =
      "[s1][palettegen] paletteuse=dither=bayer:bayer_scale=5:new=1:alpha_threshold=251"

    filter_graph =
      [
        trim_filters,
        concat_filter,
        scale_filter,
        split_filter,
        palettegen_filter,
        paletteuse_filter
      ]
      |> Enum.join(";")

    # Delay in centiseconds - otherwise it will be computed incorrectly
    final_delay = 100.0 / target_framerate

    ["-loglevel", "0", "-y"]
    |> Kernel.++(input_arguments)
    |> Kernel.++(["-lavfi", filter_graph])
    |> Kernel.++(["-f", "gif", "-final_delay", "#{final_delay}", gif])
  end

  defp clamp(duration) when duration <= 0, do: 1.0
  defp clamp(duration), do: duration
end