lib/sources/seconds/runtime_str.ex

defmodule Vtc.Source.Seconds.RuntimeStr do
  @moduledoc """
  Implementation of [Seconds](`Vtc.Source.Seconds`) for runtime strings. See
  `Vtc.Framestamp.runtime/2` for more information on this format.

  By default, this wrapper does not need to be used by callers, as the string
  implementation of the [Seconds](`Vtc.Source.Seconds`) protocol calls this type's impl
  automatically. Only use this type if you do not wish for the parser to fall back to
  other type parsing as well.
  """

  alias Vtc.Framestamp
  alias Vtc.Utils.Consts

  @enforce_keys [:in]
  defstruct [:in]

  @typedoc """
  Contains only a single field for wrapping the underlying string.
  """
  @type t() :: %__MODULE__{in: String.t()}

  @doc false
  @spec from_framestamp(Framestamp.t(), precision: non_neg_integer(), trim_zeros?: boolean()) :: t()
  def from_framestamp(framestamp, opts) do
    precision = Keyword.get(opts, :precision, 9)
    trim_zeros = Keyword.get(opts, :trim_zeros?, true)

    {seconds, negative?} =
      if Ratio.lt?(framestamp.seconds, 0),
        do: {Ratio.minus(framestamp.seconds), true},
        else: {framestamp.seconds, false}

    seconds = Decimal.div(Ratio.numerator(seconds), Ratio.denominator(seconds))

    {hours, seconds} = Decimal.div_rem(seconds, Consts.seconds_per_hour())
    {minutes, seconds} = Decimal.div_rem(seconds, Consts.seconds_per_minute())

    Decimal.Context
    seconds = Decimal.round(seconds, precision)
    seconds_floor = Decimal.round(seconds, 0, :down)
    fractal_seconds = Decimal.sub(seconds, seconds_floor)

    hours = hours |> Decimal.to_integer() |> Integer.to_string() |> String.pad_leading(2, "0")
    minutes = minutes |> Decimal.to_integer() |> Integer.to_string() |> String.pad_leading(2, "0")

    seconds_floor = seconds_floor |> Decimal.to_integer() |> Integer.to_string() |> String.pad_leading(2, "0")

    fractal_seconds = runtime_render_fractal_seconds(fractal_seconds, trim_zeros)

    # We'll add a negative sign if the framestamp is negative.
    sign = if negative?, do: "-", else: ""

    %__MODULE__{in: "#{sign}#{hours}:#{minutes}:#{seconds_floor}#{fractal_seconds}"}
  end

  # Renders fractal seconds to a string.
  @spec runtime_render_fractal_seconds(Decimal.t(), boolean()) :: String.t()
  defp runtime_render_fractal_seconds(seconds_fractal, trim_zeros?) do
    rendered = seconds_fractal |> Decimal.to_string(:normal) |> String.trim_leading("0")
    rendered = if trim_zeros?, do: String.trim_trailing(rendered, "0"), else: rendered

    case rendered do
      "." -> ".0"
      rendered -> rendered
    end
  end
end

defimpl Vtc.Source.Seconds, for: Vtc.Source.Seconds.RuntimeStr do
  @moduledoc """
  Implements [Seconds](`Vtc.Source.Seconds`) protocol for Premiere ticks.
  """

  alias Vtc.Framerate
  alias Vtc.Source.Seconds
  alias Vtc.Source.Seconds.RuntimeStr
  alias Vtc.Utils.Consts
  alias Vtc.Utils.Parse

  @runtime_regex ~r/^(?P<negative>-)?((?P<section_1>[0-9]+)[:|;])?((?P<section_2>[0-9]+)[:|;])?(?P<seconds>[0-9]+(\.[0-9]+)?)$/

  @spec seconds(RuntimeStr.t(), Framerate.t()) :: Seconds.result()
  def seconds(runtime_str, rate) do
    with {:ok, matched} <- Parse.apply_regex(@runtime_regex, runtime_str.in) do
      matched
      |> runtime_matched_to_second()
      |> Seconds.seconds(rate)
    end
  end

  @spec runtime_matched_to_second(map()) :: Ratio.t()
  defp runtime_matched_to_second(matched) do
    negative? = Map.fetch!(matched, "negative") == "-"
    sections = Parse.extract_time_sections(matched, 2)

    {minutes, sections} = Parse.pop_time_section(sections)
    {hours, _} = Parse.pop_time_section(sections)

    seconds_for_hours = Ratio.new(hours * Consts.seconds_per_hour())
    minutes_for_hours = Ratio.new(minutes * Consts.seconds_per_minute())

    matched
    |> Map.fetch!("seconds")
    |> Decimal.new()
    |> Ratio.new()
    |> Ratio.add(seconds_for_hours)
    |> Ratio.add(minutes_for_hours)
    |> then(fn seconds -> if negative?, do: Ratio.minus(seconds), else: seconds end)
  end
end