lib/framerate.ex

defmodule Vtc.Framerate do
  @moduledoc """
  The rate at which a video file frames are played back.

  Framerate is measured in frames-per-second (24/1 = 24 frames-per-second).

  ## Struct Fields

  - `playback`: The rational representation of the real-world playback speed as a
    fraction in frames-per-second.

  - `ntsc`: Atom representing which, if any, NTSC convention this framerate adheres to.
  """
  alias Vtc.Framerate.ParseError
  alias Vtc.Utils.Rational

  @enforce_keys [:playback, :ntsc]
  defstruct [:playback, :ntsc]

  @typedoc """
  Enum of `Ntsc` types.

  ## Values

  - `:non_drop` A non-drop NTSC value.
  - `:drop` A drop-frame ntsc value.
  - `nil`: Not an NTSC value

  For more information on NTSC standards and framerate conventions, see
  [Frame.io's](frame.io)
  [blogpost](https://blog.frame.io/2017/07/17/timecode-and-frame-rates) on the subject.
  """
  @type ntsc() :: :non_drop | :drop | nil

  @typedoc """
  Type of `Framerate`
  """
  @type t :: %__MODULE__{playback: Rational.t(), ntsc: ntsc()}

  @doc """
  The rational representation of the timecode timebase speed as a fraction in
  frames-per-second.
  """
  @spec timebase(t()) :: Rational.t()
  def timebase(%{ntsc: nil} = framerate), do: framerate.playback
  def timebase(framerate), do: Rational.round(framerate.playback)

  @typedoc """
  Type returned by `new/2`
  """
  @type parse_result() :: {:ok, t()} | {:error, ParseError.t()}

  @doc """
  Creates a new Framerate with a playback speed or timebase.

  ## Arguments

  - `rate`: Either the playback rate or timebase. For NTSC framerates, the value will
    be rounded to the nearest correct value.

  - `ntsc`: Atom representing the which (or whether an) NTSC standard is being used.

  - `coerce_seconds_per_frame?`: If `true`, then values such as `1/24` are assumed to be
    in seconds-per-frame format and automatically converted to `24/1`. Useful when you want
    to convert strings from multiple sources when some are seconds-per-frame and others are
    frames-per-second. NOTE: if you expect to be dealing with record-rate values for timelapse
    use at your own risk!

  NOTE: Floats cannot be passed if the rate is not NTSC and the value is not a while
  number, as there is no way to know the precise time do to floating-point errors.
  """
  @spec new(Rational.t() | float() | String.t(), ntsc(), boolean()) :: parse_result()
  def new(rate, ntsc, coerce_seconds_per_frame? \\ true)

  def new(rate, nil, _)
      when is_float(rate) and rate != Kernel.floor(rate),
      do: {:error, %ParseError{reason: :imprecise}}

  def new(rate, ntsc, coerce?)
      when is_float(rate) or is_integer(rate) or is_struct(rate, Ratio),
      do: rate |> Ratio.new(1) |> new_core(ntsc, coerce?)

  def new(rate, ntsc, coerce?) when is_binary(rate) do
    # for binaries we need to try to match integer, float, and rational string
    # representations
    parsers = [
      &Integer.parse/1,
      &Float.parse/1,
      &parse_rational_string/1
    ]

    parsers
    |> Stream.map(fn parser -> parser.(rate) end)
    |> Enum.find_value(:error, fn
      {parsed, ""} -> parsed
      _ -> false
    end)
    |> then(fn
      :error -> {:error, %ParseError{reason: :unrecognized_format}}
      value -> new(value, ntsc, coerce?)
    end)
  end

  @doc """
  As `new/2` but raises an error instead.
  """
  @spec new!(Rational.t() | float() | String.t(), ntsc(), boolean()) :: t()
  def new!(rate, ntsc, coerce_seconds_per_frame? \\ true) do
    case new(rate, ntsc, coerce_seconds_per_frame?) do
      {:ok, framerate} -> framerate
      {:error, error} -> raise error
    end
  end

  # validates that a rate is a proper drop-frame framerate.
  @spec validate_drop(Ratio.t(), ntsc()) :: :ok | {:error, ParseError.t()}
  defp validate_drop(rate, :drop) do
    case Ratio.div(rate, Ratio.new(30_000, 1_001)) do
      whole_number when is_integer(whole_number) -> :ok
      _ -> {:error, %ParseError{reason: :bad_drop_rate}}
    end
  end

  defp validate_drop(_, _), do: :ok

  # The core parser used to parse a rational or integer rate value.
  @spec new_core(Rational.t(), ntsc(), boolean()) :: parse_result()
  defp new_core(rate, ntsc, coerce_seconds_per_frame?) do
    # validate that our ntsc atom is one of the acceptable values.
    with :ok <- validate_ntsc(ntsc),
         rate <- coerce_seconds_per_frame(rate, coerce_seconds_per_frame?),
         rate <- coerce_ntsc_rate(rate, ntsc),
         :ok <- validate_drop(rate, ntsc) do
      {:ok, %__MODULE__{playback: rate, ntsc: ntsc}}
    end
  end

  # validates that the ntsc atom is one of our allowed values.
  @spec validate_ntsc(ntsc()) :: :ok | {:error, ParseError.t()}
  defp validate_ntsc(ntsc) when ntsc in [:drop, :non_drop, nil], do: :ok
  defp validate_ntsc(_), do: {:error, %ParseError{reason: :invalid_ntsc}}

  # coerces a rate to the closest proper NTSC playback rate.
  @spec coerce_ntsc_rate(Ratio.t(), ntsc()) :: Ratio.t()
  defp coerce_ntsc_rate(rate, nil), do: rate
  defp coerce_ntsc_rate(%Ratio{denominator: 1001} = rate, _), do: rate

  defp coerce_ntsc_rate(rate, _),
    do: rate |> Rational.round() |> Ratio.mult(Ratio.new(1000, 1001))

  # Coerces timebase to framerate by flipping the numberator and denominator.
  @spec coerce_seconds_per_frame(Rational.t(), boolean()) :: Rational.t()
  defp coerce_seconds_per_frame(%Ratio{numerator: x, denominator: y}, true)
       when x < y,
       do: Ratio.new(y, x)

  defp coerce_seconds_per_frame(rate, _), do: rate

  # Parses a rational string value like '24/1'. Conforms to the same API as
  # `Integer.parse/1` and `Float.parse/1`.
  @spec parse_rational_string(String.t()) :: {Rational.t(), String.t()} | :error
  defp parse_rational_string(binary) do
    case String.split(binary, "/") do
      [_, _] = split ->
        split
        |> Enum.map(&String.to_integer/1)
        |> then(fn [x, y] -> {Ratio.new(x, y), ""} end)

      _ ->
        :error
    end
  end

  @doc """
  Returns true if the value represents and NTSC framerate, therefore will return true
  on a Framerate with an `:ntsc` value of `:non_drop` and `:drop`.
  """
  @spec ntsc?(t()) :: boolean()
  def ntsc?(%{ntsc: nil}), do: false
  def ntsc?(_), do: true
end

defimpl Inspect, for: Vtc.Framerate do
  alias Vtc.Framerate

  @spec inspect(Framerate.t(), Elixir.Inspect.Opts.t()) :: String.t()
  def inspect(rate, _opts) do
    float_str =
      Ratio.to_float(rate.playback)
      |> Float.round(2)
      |> Float.to_string()

    ntsc_string = if Framerate.ntsc?(rate), do: " NTSC", else: " fps"

    drop_string =
      case rate.ntsc do
        :non_drop -> " NDF"
        :drop -> " DF"
        nil -> ""
      end

    "<#{float_str}#{ntsc_string}#{drop_string}>"
  end
end

defimpl String.Chars, for: Vtc.Framerate do
  alias Vtc.Framerate

  @spec to_string(Framerate.t()) :: String.t()
  def to_string(term), do: inspect(term)
end