lib/framestamp.ex

defmodule Vtc.Framestamp do
  @moduledoc """
  Identifies a particular frame in a media stream.

  New Framestamp values are created by `with_seconds/3` and `with_frames/2`.

  Vtc's philosophy of working with Timecode is defined by two major conceits:

  1. A frame identifier is incomplete without a framerate.
     [More here](Vtc.Framestamp.html#module-why-include-framerate).

  2. All frame identifiers commonly used in Video production boil down to either
     the real-world seconds-since-midnight that frame occurred, OR a sequential index
     number. [More here](Vtc.Framestamp.html#module-parsing-seconds-t-or-frames-t).

  ## What is a framestamp?

  Framestamps are a way to identify a media stream frame without requiring any
  additional information. On a technical level, a framestamp is comprised of:

  - The real-world time that a frame occurred at, as represented by a rational value,
    measured in seconds since SMPTE timecode "midnight".

  - The framerate of the media the framestamp was generated for, as represented by a
    rational frames-per-second value.

  - Any associated metadata about the source representation the framestamp was parsed
    from, such as SMPTE NTSC non-drop timecode.

  A fully-formed framestamp for `01:00:00:00` at `23.98 NTSC` would be
  `18018/5 @ 24000/1001 NTSC non-drop`.

  ### Why prefer seconds?

  SMPTE [timecode](history.html) is the canonical way frames are identified in
  professional video workflows. As a human-readable data type, timecode strings are
  great! You can easily locate, compare, and add timecode strings at-a-glance.

  Why then, does Vtc come up with a new representation?

  Well, SMPTE timecode strings are *not* as great for computers. Let's take a quick look
  at what we want from a good frame identifier:

  - Uniquely identifies a frame in a specific video stream.

  - Sortable by real-world occurrence.

  - Easily added/subtracted to each other.

  - All of the above, in mixed-framerate contexts.

  The last point is key, timecode is great... *if* all of your media is running at the
  same framerate. For instance, when syncing media streams between two devices -- one
  running at 24fps, and one running at 48fps -- `01:00:00:13` and `01:00:00:26` are
  equivalent values, as they were captured at the same point in time, and should be
  synced together. Timecode is an expression of *frame index* more than *frame seconds*,
  and as such, cannot be lexically sorted in mixed-rate settings. Further,
  a computer cannot add "01:30:00:00" to "01:00:00:00" without converting it to some
  sort of numerical value.

  Many programs convert timecode directly to an integer frame number for arithmetic and
  comparison operations where each frame on the clock is issued a continuous index,
  with `0` as `00:00:00:00`. Frame numbers, though, have the same issue with mixed-rate
  values as timecode; `26` at 48 frames-per-second represents the same real-world time
  as `13` at 24 frames-per-seconds, and preserving that equality is important for
  operations like jam-syncing.

  So that leaves us with real-world seconds. Convert timecode values -- even ones
  captured in mixed rates -- to seconds, then add and sort to your heart's content.

  ### Why rational numbers?

  We'll avoid a deep-dive over why we use a rational value over a float or decimal, but
  you can read more on that choice [here](the_rational_rationale.html).

  The short version is that many common SMPTE-specified framerates are defined as
  irrational numbers. For instance, `23.98 NTSC` is defined as `24000/1001`
  frames-per-second.

  In order to avoid off-by-one errors when using `seconds`, we need to avoid resolving
  values like `1001/24000` -- the value for frame `1` at `23.98 NTSC` -- into any sort
  of decimal representation, since `1001/24000` is an irrational value and cannot be
  cleanly represented as a decimal. It's digits ride off into the sunset.

  ### Why include framerate?

  SMPTE timecode does not include a framerate in it's specification for frame
  identifiers, i.e `01:00:00:00`. So why does Vtc?

  Lets say that we are working with a given video file, and you are handed the timecode
  `01:00:00:12`. What frame does that belong to?

  Without a framerate, you cannot know. If we are talking about `23.98 NTSC` media, it
  belongs to frame `86,400`, but if we are talking about `59.94 NTSC NDF`, frame then
  it belongs to frame `216,000`, and if we are talking about `59.94 NTSC DF` media then
  it belongs to frame `215,784`.

  What about the other direction? We need to calculate the SMPTE timecode for frame
  `48`, which we previously parsed from a timecode. Well if it was originally parsed
  using `23.98 NTSC` footage, then it is TC `00:00:02:00`, but if it is `59.94 NTSC`
  then it is TC `00:00:00:48`. Framerate is implicitly required for a SMPTE timecode
  to be comprehensible.

  The story is the same with seconds. How many seconds does `01:00:00:00` represent?
  At `23.98 NTSC`, it represents `18018/5` seconds, but at `24fps true` it represents
  `3600/1` seconds.

  We cannot know what frame a seconds value represents, or what seconds value a frame
  represents, without knowing that scalar value's associated framerate. It's like having
  a timestamp without a timezone. Even in systems where all timestamps are converted to
  UTC, we often keep the timezone information around because it's just too useful in
  mixed-timezone settings, and you can't be *sure* what a given timestamp represents
  in a vacuum if you don't have the associated timezone.

  Framerate -- especially in mixed rate settings, which Vtc considers a first-class use
  case -- is required to sensibly execute many operations, like casting in an out of
  SMPTE Timecode, adding two timecodes together, etc.

  For this reason we package the framerate of our media stream together with the scalar
  value that represents a frame in that stream, and take the onus of transporting these
  two values together off of the caller.

  ## Struct Fields

  - `seconds`: The real-world seconds elapsed since 'midnight' as a rational value.

  - `rate`: the [Framerate](`Vtc.Framerate`) of the `Framestamp`.

  ## Parsing: Seconds.t() or Frames.t()

  Parsing functions pre-append `with_` to their name. When you give a value to a parsing
  function, it is the same value that would be returned by the equivalent unit
  conversion. So a value passed to [with_frames](`Vtc.Framestamp.with_frames/2`) is the
  same value [frames](`Vtc.Framestamp.frames/1`) would return:

  ```elixir
  iex> {:ok, framestamp} = Framestamp.with_frames(24, Rates.f23_98())
  iex> inspect(framestamp)
  "<00:00:01:00 <23.98 NTSC>>"
  iex> Framestamp.frames(framestamp)
  24
  ```

  The `Framestamp` module only has two basic construction / parsing methods:
  [with_seconds](`Vtc.Framestamp.with_seconds/2`) and
  [with_frames](`Vtc.Framestamp.with_frames/2`).

  At first blush, this may seem... odd. Where is `with_timecode/2`? Or
  `with_premiere_ticks/2`? We can render these formats, so why isn't there a parser for
  them? Well there is, sort of: the two functions above.

  Vtc's second major conceit is that all of the various ways of representing a
  video frame's timestamp boil down to EITHER:

  - a) A representation of an index number for that frame

  OR

  - b) A representation of the real-world seconds the frame occurred at.

  SMPTE timecode is really a human-readable way to represent a frame number. Same with
  film feet+frames.

  Premiere Ticks, on the other hand, represents a real-world seconds value, as broken
  down in `1/254_016_000_000ths` of a second.

  Instead of polluting the module's namespace with a range of constructors, Vtc declares
  a [Frames](`Vtc.Source.Frames`) protocol for types that represent a frame count, and a
  [Seconds](`Vtc.Source.Seconds`) protocol for types that represent a time-scalar.

  All framestamp representations eventually get funneled through one of these
  protocols. For instance, when the `String` implementation of the protocol detects a
  SMPTE timecode string, it wraps the value in a
  [SMPTETimecodeStr](`Vtc.Source.Frames.SMPTETimecodeStr`) struct which handles converting that
  string to a frame number thorough implementing the [Frames](`Vtc.Source.Frames`)
  protocol. That frame number is then taken by
  [with_frames](`Vtc.Framestamp.with_frames/2`) and converted to a rational seconds
  value.

  Going through protocols allows callers to define their own types that work with Vtc's
  parsing functions directly.

  ## Sorting Support

  [Framestamp](`Vtc.Framestamp`) implements `compare/2`, and as such, can be used wherever
  the standard library calls for a `Sorter` module. Let's see it in action:

  ```elixir
  iex> stamp_01 = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> stamp_02 = Framestamp.with_frames!("02:00:00:00", Rates.f23_98())
  iex>
  iex> sorted = Enum.sort([stamp_02, stamp_01], Framestamp)
  iex> inspect(sorted)
  "[<01:00:00:00 <23.98 NTSC>>, <02:00:00:00 <23.98 NTSC>>]"
  iex> sorted = Enum.sort([stamp_01, stamp_02], {:desc, Framestamp})
  iex> inspect(sorted)
  "[<02:00:00:00 <23.98 NTSC>>, <01:00:00:00 <23.98 NTSC>>]"
  iex> max = Enum.max([stamp_02, stamp_01], Framestamp)
  iex> inspect(max)
  "<02:00:00:00 <23.98 NTSC>>"
  iex> min = Enum.min([stamp_02, stamp_01], Framestamp)
  iex> inspect(min)
  "<01:00:00:00 <23.98 NTSC>>"
  iex> data_01 = %{id: 2, tc: stamp_01}
  iex> data_02 = %{id: 1, tc: stamp_02}
  iex> sorted = Enum.sort_by([data_02, data_01], & &1.tc, Framestamp)
  iex> inspect(sorted)
  "[%{id: 2, tc: <01:00:00:00 <23.98 NTSC>>}, %{id: 1, tc: <02:00:00:00 <23.98 NTSC>>}]"
  ```

  ## Arithmetic Autocasting

  For operators that take two `Framestamp` values, like `add/3` or `compare/2`, as long
  as one argument is a [Framestamp](`Vtc.Framestamp`) value, `a` or `b` May be any value
  that implements the [Frames](`Vtc.Source.Frames`) protocol, such as a timecode string,
  and will be assumed to be the same framerate as the other.

  > #### Production code {: .tip}
  >
  > Autocasting exists to support quick scratch scripts and we suggest that it not be
  > relied upon in production application code.

  If parsing the value fails during casting, the function raises a
  `Vtc.Framestamp.ParseError`.

  ## Using as an Ecto Type

  See [PgFramestamp](`Vtc.Ecto.Postgres.PgFramestamp`) for information on how to use
  `Framerate` in your postgres database as a native type.
  """
  use Vtc.Ecto.Postgres.Utils

  import Kernel, except: [div: 2, rem: 2, abs: 1]

  alias Vtc.Ecto.Postgres.PgFramestamp
  alias Vtc.FilmFormat
  alias Vtc.Framerate
  alias Vtc.Framestamp
  alias Vtc.Framestamp.Eval
  alias Vtc.Framestamp.ParseError
  alias Vtc.SMPTETimecode.Sections
  alias Vtc.Source.Frames
  alias Vtc.Source.Frames.FeetAndFrames
  alias Vtc.Source.Frames.SMPTETimecodeStr
  alias Vtc.Source.Seconds
  alias Vtc.Source.Seconds.PremiereTicks
  alias Vtc.Source.Seconds.RuntimeStr
  alias Vtc.Utils.DropFrame
  alias Vtc.Utils.MixedRateOps
  alias Vtc.Utils.Rational

  @enforce_keys [:seconds, :rate]
  defstruct [:seconds, :rate]

  @typedoc """
  [Framestamp](`Vtc.Framestamp`) type.
  """
  @type t() :: %__MODULE__{
          seconds: Ratio.t(),
          rate: Framerate.t()
        }

  @typedoc """
  Valid values for rounding options.

  - `:closest`: Round the to the closet whole frame. Rounds away from zero when
    value is equidistant from two whole-frames.

  - `:floor`: Always round down to the closest whole-frame. Negative numbers round away
     from zero

  - `:ciel`: Always round up to the closest whole-frame. Negative numbers round towards
     zero.

  - `:trunc`: Always round towards zero to the closest whole frame. Negative numbers
    round up and positive numbers round down.

  - `:off`: Do not round. Will always raise if result would represent a non-whole-frame
    value.
  """
  @type round() :: :closest | :floor | :ceil | :trunc | :off

  @typedoc """
  Describes which side to inherit the framerate from in mixed-rate arithmetic.
  """
  @type inherit_opt() :: :left | :right | false

  @typedoc """
  Type returned by `with_seconds/3` and `with_frames/3`.
  """
  @type parse_result() :: {:ok, t()} | {:error, ParseError.t() | %ArgumentError{}}

  @doc section: :parse
  @doc """
  Returns a new [Framestamp](`Vtc.Framestamp`) with a `:seconds` field value equal to the
  `seconds` arg.

  ## Arguments

  - `seconds`: A value which can be represented as a number of real-world seconds.
    Must implement the [Seconds](`Vtc.Source.Seconds`) protocol.

  - `rate`: Frame-per-second playback value of the framestamp.

  ## Options

  - `round`: How to round the result with regards to whole-frames. If set to `:off`,
    will return an error if the provided `seconds` value does not exactly represent
    a whole-number frame count. Default: `:closest`.

  ## Examples

  Accepts runtime strings...

  ```elixir
  iex> result = Framestamp.with_seconds("01:00:00.5", Rates.f23_98())
  iex> inspect(result)
  "{:ok, <00:59:56:22 <23.98 NTSC>>}"
  ```

  ... floats...

  ```elixir
  iex> result = Framestamp.with_seconds(3600.5, Rates.f23_98())
  iex> inspect(result)
  "{:ok, <00:59:56:22 <23.98 NTSC>>}"
  ```

  ... integers...

  ```elixir
  iex> result = Framestamp.with_seconds(3600, Rates.f23_98())
  iex> inspect(result)
  "{:ok, <00:59:56:10 <23.98 NTSC>>}"
  ```

  ... integer strings...

  ```elixir
  iex> result = Framestamp.with_seconds("3600", Rates.f23_98())
  iex> inspect(result)
  "{:ok, <00:59:56:10 <23.98 NTSC>>}"
  ```

  ... and float strings.

  ```elixir
  iex> result = Framestamp.with_seconds("3600.5", Rates.f23_98())
  iex> inspect(result)
  "{:ok, <00:59:56:22 <23.98 NTSC>>}"
  ```

  ## Premiere Ticks

  The `Vtc.Source.Seconds.PremiereTicks` struck implements the
  [Seconds](`Vtc.Source.Seconds`) protocol and can be used to parse the format. This
  struct is not a general-purpose Module for the unit, and only exists to hint to the
  parsing function how it should be processed:

  ```elixir
  iex> alias Vtc.Source.Seconds.PremiereTicks
  iex>
  iex> input = %PremiereTicks{in: 254_016_000_000}
  iex>
  iex> result = Framestamp.with_seconds!(input, Rates.f23_98())
  iex> inspect(result)
  "<00:00:01:00 <23.98 NTSC>>"
  ```
  """
  @spec with_seconds(
          Seconds.t(),
          Framerate.t(),
          opts :: [round: round() | :off]
        ) :: parse_result()
  def with_seconds(seconds, rate, opts \\ []) do
    round = Keyword.get(opts, :round, :closest)

    with {:ok, seconds} <- Seconds.seconds(seconds, rate),
         :ok <- validate_whole_frames(seconds, rate, round) do
      seconds = with_seconds_round_to_frame(seconds, rate, round)
      {:ok, %__MODULE__{seconds: seconds, rate: rate}}
    end
  end

  # Rounds seconds value to the nearest whole-frame.
  @spec with_seconds_round_to_frame(Ratio.t(), Framerate.t(), round() | :off) :: Ratio.t()
  defp with_seconds_round_to_frame(seconds, _, :off), do: seconds

  defp with_seconds_round_to_frame(seconds, rate, round) do
    rate.playback
    |> Ratio.mult(seconds)
    |> Rational.round(round)
    |> Ratio.new()
    |> Ratio.div(rate.playback)
  end

  # Validates that seconds is cleanly divisible by `rate.playback`.
  @spec validate_whole_frames(Ratio.t(), Framerate.t(), :off | round()) :: :ok | {:error, ParseError.t()}
  defp validate_whole_frames(seconds, rate, :off) do
    remainder = seconds |> Ratio.mult(rate.playback) |> Rational.rem(Ratio.new(1))

    if Ratio.eq?(remainder, Ratio.new(0)) do
      :ok
    else
      {:error, %ParseError{reason: :partial_frame}}
    end
  end

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

  @doc section: :parse
  @doc """
  As `with_seconds/3`, but raises on error.
  """
  @spec with_seconds!(
          Seconds.t(),
          Framerate.t(),
          opts :: [round: round() | :off]
        ) :: t()
  def with_seconds!(seconds, rate, opts \\ []) do
    seconds
    |> with_seconds(rate, opts)
    |> handle_raise_function()
  end

  @doc section: :parse
  @doc """
  Returns a new [Framestamp](`Vtc.Framestamp`) with a `frames/2` return value equal to the
  `frames` arg.

  ## Arguments

  - `frames`: A value which can be represented as a frame number / frame count. Must
    implement the [Frames](`Vtc.Source.Frames`) protocol.

  - `rate`: Frame-per-second playback value of the framestamp.

  ## Options

  - `round`: How to round the result with regards to whole-frames. Default: `:closest`.

  ## Examples

  Accepts SMPTE timecode strings...

  ```elixir
  iex> result = Framestamp.with_frames("01:00:00:00", Rates.f23_98())
  iex> inspect(result)
  "{:ok, <01:00:00:00 <23.98 NTSC>>}"
  ```

  ... feet+frames strings...

  ```elixir
  iex> result = Framestamp.with_frames("5400+00", Rates.f23_98())
  iex> inspect(result)
  "{:ok, <01:00:00:00 <23.98 NTSC>>}"
  ```

  By default, feet+frames is interpreted as 35mm, 4perf film. You can use the
  [FeetAndFrames](`Vtc.Source.Frames.FeetAndFrames`) struct to parse other film formats:

  ```elixir
  iex> alias Vtc.Source.Frames.FeetAndFrames
  iex>
  iex> {:ok, feet_and_frames} = FeetAndFrames.from_string("5400+00", film_format: :ff16mm)
  iex>
  iex> result = Framestamp.with_frames(feet_and_frames, Rates.f23_98())
  iex> inspect(result)
  "{:ok, <01:15:00:00 <23.98 NTSC>>}"
  ```

  ... integers...

  ```elixir
  iex> result = Framestamp.with_frames(86_400, Rates.f23_98())
  iex> inspect(result)
  "{:ok, <01:00:00:00 <23.98 NTSC>>}"
  ```

  ... and integer strings.

  ```elixir
  iex> result = Framestamp.with_frames("86400", Rates.f23_98())
  iex> inspect(result)
  "{:ok, <01:00:00:00 <23.98 NTSC>>}"
  ```
  """
  @spec with_frames(Frames.t(), Framerate.t()) :: parse_result()
  def with_frames(frames, rate) do
    with {:ok, frames} <- Frames.frames(frames, rate),
         :ok <- validate_drop_frame_number(frames, rate) do
      frames
      |> Ratio.new()
      |> Ratio.div(rate.playback)
      |> with_seconds(rate, round: :off)
    end
  end

  @doc section: :parse
  @doc """
  As `Framestamp.with_frames/3`, but raises on error.
  """
  @spec with_frames!(Frames.t(), Framerate.t()) :: t()
  def with_frames!(frames, rate) do
    frames
    |> with_frames(rate)
    |> handle_raise_function()
  end

  @spec validate_drop_frame_number(integer(), Framerate.t()) :: :ok | {:error, ParseError.t()}
  defp validate_drop_frame_number(frames, %{ntsc: :drop} = rate) do
    if Kernel.abs(frames) > DropFrame.max_frames(rate) do
      {:error, %ParseError{reason: :drop_frame_maximum_exceeded}}
    else
      :ok
    end
  end

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

  @non_drop_midnight_seconds Ratio.new(432_432, 5)
  @drop_midnight_seconds Ratio.new(53_999_946, 625)
  @whole_frame_seconds Ratio.new(86_400, 1)

  @doc section: :parse
  @doc """
  Return a [Framestamp](`Vtc.Framestamp`) for SMPTE timecode `24:00:00:00` at `rate`.

  Since all non-drop timecodes share the same midnight seconds value, and all drop
  timecodes similarly share the same midnight value, this function is significantly more
  performant than using the regular parsing functions.

  Will return error if `rate` is not a drop, non-drop, or whole-frame SMPTE timecode.

  ## Examples

  ```elixir
  iex> {:ok, framestamp} = Framestamp.smpte_midnight(Rates.f23_98())
  iex> inspect(framestamp)
  "<24:00:00:00 <23.98 NTSC>>"
  ```
  """
  @spec smpte_midnight(Framerate.t()) :: {:ok, t()} | {:error, Framerate.InvalidSMPTEValueError.t()}
  def smpte_midnight(%{ntsc: :non_drop} = rate) do
    stamp = %Framestamp{seconds: @non_drop_midnight_seconds, rate: rate}
    {:ok, stamp}
  end

  def smpte_midnight(%{ntsc: :drop} = rate) do
    stamp = %Framestamp{seconds: @drop_midnight_seconds, rate: rate}
    {:ok, stamp}
  end

  def smpte_midnight(rate) do
    if rate.playback.denominator == 1 do
      stamp = %Framestamp{seconds: @whole_frame_seconds, rate: rate}
      {:ok, stamp}
    else
      {:error, %Framerate.InvalidSMPTEValueError{}}
    end
  end

  @doc section: :parse
  @doc """
  As `smpte_midnight/1`, but raises on error.

  ## Raises

  - [InvalidSMPTEValueError](`Vtc.Framerate.InvalidSMPTEValueError`) if `rate` is not
    NTSC drop or non-drop.
  """
  @spec smpte_midnight!(Framerate.t()) :: t()
  def smpte_midnight!(rate) do
    rate
    |> smpte_midnight()
    |> handle_raise_function()
  end

  @doc section: :manipulate
  @doc """
  Rebases `framestamp` to a new framerate.

  The real-world seconds are recalculated using the same frame count as if they were
  being played back at `new_rate` instead of `framestamp.rate`.

  ## Examples

  ```elixir
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> {:ok, rebased} = Framestamp.rebase(framestamp, Rates.f47_95())
  iex> inspect(rebased)
  "<00:30:00:00 <47.95 NTSC>>"
  ```
  """
  @spec rebase(t(), Framerate.t()) :: parse_result()
  def rebase(%{rate: rate} = framestamp, rate), do: {:ok, framestamp}
  def rebase(framestamp, new_rate), do: framestamp |> frames() |> with_frames(new_rate)

  @doc section: :manipulate
  @doc """
  As `rebase/2`, but raises on error.
  """
  @spec rebase!(t(), Framerate.t()) :: t()
  def rebase!(framestamp, new_rate), do: framestamp |> rebase(new_rate) |> handle_raise_function()

  @doc section: :manipulate
  @doc """
  Wrap `value` to the nearest valid TOD (time-of-day) timecode.

  Framestamps with a SMPTE timecode of less than `00:00:00:00` will have `24:00:00:00`
  recursively added until they are positive.

  Framestamps with a SMPTE timecode of greater than or equal to `24:00:00:00` will have
  `24:00:00:00` subtracted until they are less than `24:00:00:00`.

  Returns error if `value.rate` is not NTSC or whole-frame. Time-of-day timecode is not
  defined for non-SMPTE framerates.

  ## Examples

  ```elixir
  iex> {:ok, stamp} = Framestamp.with_frames("01:30:21:17", Rates.f23_98())
  iex> {:ok, stamp} = Framestamp.smpte_wrap_tod(stamp)
  iex> inspect(stamp)
  "<01:30:21:17 <23.98 NTSC>>"
  ```

  ```elixir
  iex> {:ok, stamp} = Framestamp.with_frames("24:30:21:17", Rates.f23_98())
  iex> {:ok, stamp} = Framestamp.smpte_wrap_tod(stamp)
  iex> inspect(stamp)
  "<00:30:21:17 <23.98 NTSC>>"
  ```

  ```elixir
  iex> {:ok, stamp} = Framestamp.with_frames("-01:00:00:00", Rates.f23_98())
  iex> {:ok, stamp} = Framestamp.smpte_wrap_tod(stamp)
  iex> inspect(stamp)
  "<23:00:00:00 <23.98 NTSC>>"
  ```

  ```elixir
  iex> {:ok, stamp} = Framestamp.with_frames("24:00:00:00", Rates.f23_98())
  iex> {:ok, stamp} = Framestamp.smpte_wrap_tod(stamp)
  iex> inspect(stamp)
  "<00:00:00:00 <23.98 NTSC>>"
  ```
  """
  @spec smpte_wrap_tod(t()) :: {:ok, t()} | {:error, Framerate.InvalidSMPTEValueError.t()}
  def smpte_wrap_tod(value) do
    if value.rate.ntsc == nil and value.rate.playback.denominator != 1 do
      {:error, %Framerate.InvalidSMPTEValueError{}}
    else
      %{seconds: input_seconds} = value
      one_frame_secs = Ratio.new(value.rate.playback.denominator, value.rate.playback.numerator)

      full_day =
        if value.rate.ntsc == :drop do
          full_day_stamp = smpte_midnight!(value.rate)
          full_day_stamp.seconds
        else
          one_frame_secs
          |> Ratio.mult(Framerate.smpte_timebase(value.rate))
          |> Ratio.mult(Ratio.new(60))
          |> Ratio.mult(Ratio.new(60))
          |> Ratio.mult(Ratio.new(24))
        end

      new_seconds = do_wrap_tod(value.seconds, full_day)

      case new_seconds do
        ^input_seconds ->
          {:ok, value}

        _ ->
          new_stamp = with_seconds!(new_seconds, value.rate, round: :off)
          {:ok, new_stamp}
      end
    end
  end

  @doc section: :manipulate
  @doc """
  As `smpte_wrap_tod/1`, but raises on error.

  ## Raises

  - [InvalidSMPTEValueError](`Vtc.Framerate.InvalidSMPTEValueError`) if `rate` is not
    NTSC drop or non-drop.
  """
  @spec smpte_wrap_tod!(t()) :: t()
  def smpte_wrap_tod!(value) do
    value
    |> smpte_wrap_tod()
    |> handle_raise_function()
  end

  # full day should be the equivalent of `24:00:00:00` at `value`'s rate.
  @spec do_wrap_tod(Ratio.t(), Ratio.t()) :: Ratio.t()
  defp do_wrap_tod(stamp_seconds, full_day) do
    cond do
      Ratio.lt?(stamp_seconds, Ratio.new(0)) -> do_wrap_tod(Ratio.add(stamp_seconds, full_day), full_day)
      Ratio.gte?(stamp_seconds, full_day) -> do_wrap_tod(Ratio.sub(stamp_seconds, full_day), full_day)
      true -> stamp_seconds
    end
  end

  @doc section: :compare
  @doc """
  Compare the values of `a` and `b`.

  Compatible with `Enum.sort/2`. For more on sorting non-builtin values, see
  [the Elixir documentation](https://hexdocs.pm/elixir/1.13/Enum.html#sort/2-sorting-structs).

  [auto-casts](#module-arithmetic-autocasting) [Frames](`Vtc.Source.Frames`) values.
  See `eq?/2` for more information on how equality is determined.

  ## Examples

  Using two framestamps parsed from SMPTE timecode, `01:00:00:00` NTSC is greater than
  `01:00:00:00` true because it represents more real-world time.

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> b = Framestamp.with_frames!("01:00:00:00", Rates.f24())
  iex> :gt = Framestamp.compare(a, b)
  ```

  Using a framestamp and a bare string:

  ```elixir
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> :eq = Framestamp.compare(framestamp, "01:00:00:00")
  ```
  """
  @spec compare(a :: t() | Frames.t(), b :: t() | Frames.t()) :: :lt | :eq | :gt
  def compare(a, b) do
    {a, b} = cast_op_args(a, b)
    Ratio.compare(a.seconds, b.seconds)
  end

  @doc section: :compare
  @doc """
  Returns `true` if `a` is equal to `b`.

  [auto-casts](#module-arithmetic-autocasting) [Frames](`Vtc.Source.Frames`) values.

  ## Examples

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> b = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> true = Framestamp.eq?(a, b)
  ```

  Framestamps with the *same* string timecode representation, but *different* real-world
  seconds values, are *not* equal:

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> b = Framestamp.with_frames!("01:00:00:00", Rates.f24())
  iex> false = Framestamp.eq?(a, b)
  ```

  But Framestamps with the *different* SMPTE timecode string representation, but the
  *same* real-world seconds values, *are* equal:

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:12", Rates.f23_98())
  iex> b = Framestamp.with_frames!("01:00:00:24", Rates.f47_95())
  iex> true = Framestamp.eq?(a, b)
  ```
  """
  @spec eq?(a :: t() | Frames.t(), b :: t() | Frames.t()) :: boolean()
  def eq?(a, b), do: compare(a, b) == :eq

  @doc section: :compare
  @doc """
  Returns `true` if `a` is less than `b`.

  [auto-casts](#module-arithmetic-autocasting) [Frames](`Vtc.Source.Frames`) values.
  See `eq?/2` for more information on how equality is determined.

  ## Examples

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> b = Framestamp.with_frames!("02:00:00:00", Rates.f23_98())
  iex> true = Framestamp.lt?(a, b)
  iex> false = Framestamp.lt?(b, a)
  ```
  """
  @spec lt?(a :: t() | Frames.t(), b :: t() | Frames.t()) :: boolean()
  def lt?(a, b), do: compare(a, b) == :lt

  @doc section: :compare
  @doc """
  Returns `true` if `a` is less than or equal to `b`.

  [auto-casts](#module-arithmetic-autocasting) [Frames](`Vtc.Source.Frames`) values.
  See `eq?/2` for more information on how equality is determined.
  """
  @spec lte?(a :: t() | Frames.t(), b :: t() | Frames.t()) :: boolean()
  def lte?(a, b), do: compare(a, b) in [:lt, :eq]

  @doc section: :compare
  @doc """
  Returns `true` if `a` is greater than `b`.

  [auto-casts](#module-arithmetic-autocasting) [Frames](`Vtc.Source.Frames`) values.
  See `eq?/2` for more information on how equality is determined.
  """
  @spec gt?(a :: t() | Frames.t(), b :: t() | Frames.t()) :: boolean()
  def gt?(a, b), do: compare(a, b) == :gt

  @doc section: :compare
  @doc """
  Returns `true` if `a` is greater than or equal to `b`.

  [auto-casts](#module-arithmetic-autocasting) [Frames](`Vtc.Source.Frames`) values.
  See `eq?/2` for more information on how equality is determined.
  """
  @spec gte?(a :: t() | Frames.t(), b :: t() | Frames.t()) :: boolean()
  def gte?(a, b), do: compare(a, b) in [:gt, :eq]

  @doc section: :arithmetic
  @doc """
  Add two framestamps.

  Uses the real-world seconds representation. When the rates of `a` and `b` are not
  equal, the result will inherit the framerate of `a` and be rounded to the seconds
  representation of the nearest whole-frame at that rate.

  [auto-casts](#module-arithmetic-autocasting) [Frames](`Vtc.Source.Frames`) values.

  ## Options

  - `inherit_rate`: Which side to inherit the framerate from in mixed-rate calculations.
    If `false`, this function will raise if `a.rate` does not match `b.rate`.
    Default: `false`.

  - `round`: How to round the result with respect to whole-frames when mixing
    framerates. Default: `:closest`.

  ## Examples

  Two framestamps running at the same rate:

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> b = Framestamp.with_frames!("01:30:21:17", Rates.f23_98())
  iex>
  iex> result = Framestamp.add(a, b)
  iex> inspect(result)
  "<02:30:21:17 <23.98 NTSC>>"
  ```

  Two framestamps running at different rates:

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:02", Rates.f23_98())
  iex> b = Framestamp.with_frames!("00:00:00:02", Rates.f47_95())
  iex>
  iex> result = Framestamp.add(a, b, inherit_rate: :left)
  iex> inspect(result)
  "<01:00:00:03 <23.98 NTSC>>"
  iex>
  iex> result = Framestamp.add(a, b, inherit_rate: :right)
  iex> inspect(result)
  "<01:00:00:06 <47.95 NTSC>>"
  ```

  If `:inherit_rate` is not set...

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:02", Rates.f23_98())
  iex> b = Framestamp.with_frames!("00:00:00:02", Rates.f47_95())
  iex> Framestamp.add(a, b)
  ** (Vtc.Framestamp.MixedRateArithmeticError) attempted `Framestamp.add(a, b)` where `a.rate` does not match `b.rate`. try `:inherit_rate` option to `:left` or `:right`. alternatively, do your calculation in seconds, then cast back to `Framestamp` with the appropriate framerate using `with_seconds/3`
  ```

  Using a framestamps and a bare string:

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.add(a, "01:30:21:17")
  iex> inspect(result)
  "<02:30:21:17 <23.98 NTSC>>"
  ```
  """
  @spec add(
          a :: t() | Frames.t(),
          b :: t() | Frames.t(),
          opts :: [inherit_rate: inherit_opt(), round: round()]
        ) :: t()
  def add(a, b, opts \\ []), do: do_arithmetic(a, b, :add, opts, &Ratio.add(&1, &2))

  @doc section: :arithmetic
  @doc """
  Subtract `b` from `a`.

  Uses their real-world seconds representation. When the rates of `a` and `b` are not
  equal, the result will inherit the framerate of `a` and be rounded to the seconds
  representation of the nearest whole-frame at that rate.

  [auto-casts](#module-arithmetic-autocasting) [Frames](`Vtc.Source.Frames`) values.

  ## Options

  - `inherit_rate`: Which side to inherit the framerate from in mixed-rate calculations.
    If `false`, this function will raise if `a.rate` does not match `b.rate`.
    Default: `false`.

  - `round`: How to round the result with respect to whole-frames when mixing
    framerates. Default: `:closest`.

  ## Examples

  Two framestamps running at the same rate:

  ```elixir
  iex> a = Framestamp.with_frames!("01:30:21:17", Rates.f23_98())
  iex> b = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.sub(a, b)
  iex> inspect(result)
  "<00:30:21:17 <23.98 NTSC>>"
  ```

  Two framestamps running at different rates:

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:02", Rates.f23_98())
  iex> b = Framestamp.with_frames!("00:00:00:02", Rates.f47_95())
  iex>
  iex> result = Framestamp.sub(a, b, inherit_rate: :left)
  iex> inspect(result)
  "<01:00:00:01 <23.98 NTSC>>"
  iex>
  iex> result = Framestamp.sub(a, b, inherit_rate: :right)
  iex> inspect(result)
  "<01:00:00:02 <47.95 NTSC>>"
  ```

  If `:inherit_rate` is not set...

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:02", Rates.f23_98())
  iex> b = Framestamp.with_frames!("00:00:00:02", Rates.f47_95())
  iex> Framestamp.sub(a, b)
  ** (Vtc.Framestamp.MixedRateArithmeticError) attempted `Framestamp.sub(a, b)` where `a.rate` does not match `b.rate`. try `:inherit_rate` option to `:left` or `:right`. alternatively, do your calculation in seconds, then cast back to `Framestamp` with the appropriate framerate using `with_seconds/3`
  ```

  When `b` is greater than `a`, the result is negative:

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> b = Framestamp.with_frames!("02:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.sub(a, b)
  iex> inspect(result)
  "<-01:00:00:00 <23.98 NTSC>>"
  ```

  Using a framestamps and a bare string:

  ```elixir
  iex> a = Framestamp.with_frames!("01:30:21:17", Rates.f23_98())
  iex>
  iex> result = Framestamp.sub(a, "01:00:00:00")
  iex> inspect(result)
  "<00:30:21:17 <23.98 NTSC>>"
  ```
  """
  @spec sub(
          a :: t() | Frames.t(),
          b :: t() | Frames.t(),
          opts :: [inherit_rate: inherit_opt(), round: round()]
        ) :: t()
  def sub(a, b, opts \\ []), do: do_arithmetic(a, b, :sub, opts, &Ratio.sub(&1, &2))

  # Runs a (Framestamp, Framestamp) arithmetic operation.
  @spec do_arithmetic(
          a :: t() | Frames.t(),
          b :: t() | Frames.t(),
          func_name :: :add | :sub,
          opts :: [inherit_rate: inherit_opt(), round: round()],
          (Ratio.t(), Ratio.t() -> Ratio.t())
        ) :: t()
  defp do_arithmetic(a, b, func_name, opts, seconds_operation) do
    inherit_rate = Keyword.get(opts, :inherit_rate, false)

    case MixedRateOps.get_rate(a, b, inherit_rate, func_name) do
      {:ok, new_rate} ->
        {a, b} = cast_op_args(a, b)
        parse_opts = Keyword.take(opts, [:round])

        a.seconds
        |> seconds_operation.(b.seconds)
        |> with_seconds!(new_rate, parse_opts)

      {:error, error} ->
        raise error
    end
  end

  # Casts args for ops with two values as long as at least one argument is a
  # `Framestamp`. The non-`Framestamp` argument inherits the `Framerate` of the
  # `Framestamp` argument.
  @spec cast_op_args(t() | Frames.t(), t() | Frames.t()) :: {t(), t()}
  defp cast_op_args(%__MODULE__{} = a, %__MODULE__{} = b), do: {a, b}
  defp cast_op_args(%__MODULE__{} = a, b), do: {a, with_frames!(b, a.rate)}
  defp cast_op_args(a, %__MODULE__{} = b), do: {with_frames!(a, b.rate), b}

  @doc section: :arithmetic
  @doc """
  Scales `a` by `b`.

  The result will inherit the framerate of `a` and be rounded to the seconds
  representation of the nearest whole-frame based on the `:round` option.

  ## Options

  - `round`: How to round the result with respect to whole-frame values. Defaults to
    `:closest`.

  ## Examples

  ```elixir
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.mult(a, 2)
  iex> inspect(result)
  "<02:00:00:00 <23.98 NTSC>>"

  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.mult(a, 0.5)
  iex> inspect(result)
  "<00:30:00:00 <23.98 NTSC>>"
  ```
  """
  @spec mult(
          a :: t(),
          b :: Ratio.t() | number(),
          opts :: [round: round()]
        ) :: t()
  def mult(a, b, opts \\ []), do: a.seconds |> Ratio.mult(Ratio.new(b)) |> with_seconds!(a.rate, opts)

  @doc section: :arithmetic
  @doc """
  Divides `dividend` by `divisor`.

  The result will inherit the framerate of `dividend` and rounded to the nearest
  whole-frame based on the `:round` option.

  ## Options

  - `round`: How to round the result with respect to whole-frame values. Defaults to
    `:trunc` to match `divmod` and the expected meaning of `div` to mean integer
    division in elixir.

  ## Examples

  ```elixir
  iex> dividend = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.div(dividend, 2)
  iex> inspect(result)
  "<00:30:00:00 <23.98 NTSC>>"

  iex> dividend = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.div(dividend, 0.5)
  iex> inspect(result)
  "<02:00:00:00 <23.98 NTSC>>"
  ```
  """
  @spec div(
          dividend :: t(),
          divisor :: Ratio.t() | number(),
          opts :: [round: round()]
        ) :: t()
  def div(dividend, divisor, opts \\ []) do
    opts = Keyword.put_new(opts, :round, :trunc)
    dividend.seconds |> Ratio.div(Ratio.new(divisor)) |> with_seconds!(dividend.rate, opts)
  end

  @doc section: :arithmetic
  @doc """
  Divides the total frame count of `dividend` by `divisor` and returns both a quotient
  and a remainder.

  The quotient returned is equivalent to `Framestamp.div/3` with the `:round` option set
  to `:trunc`.

  ## Options

  - `round_frames`: How to round the frame count before doing the divrem operation.
    Default: `:closest`.

  - `round_remainder`: How to round the remainder frames when a non-whole frame would
    be the result. Default: `:closest`.

  ## Examples

  ```elixir
  iex> dividend = Framestamp.with_frames!("01:00:00:01", Rates.f23_98())
  iex>
  iex> result = Framestamp.divrem(dividend, 4)
  iex> inspect(result)
  "{<00:15:00:00 <23.98 NTSC>>, <00:00:00:01 <23.98 NTSC>>}"
  ```
  """
  @spec divrem(
          dividend :: t(),
          divisor :: Ratio.t() | number(),
          opts :: [round_frames: round(), round_remainder: round()]
        ) :: {t(), t()}
  def divrem(dividend, divisor, opts \\ []) do
    with {round_frames, round_remainder} <- validate_divrem_rounding(opts) do
      %{rate: rate} = dividend

      {quotient, remainder} =
        dividend
        |> frames(round: round_frames)
        |> Ratio.new()
        |> Rational.divrem(Ratio.new(divisor))

      remainder = Rational.round(remainder, round_remainder)

      {with_frames!(quotient, rate), with_frames!(remainder, rate)}
    end
  end

  @doc section: :arithmetic
  @doc """
  Divides the total frame count of `dividend` by `devisor`, and returns the remainder.

  The quotient is truncated before the remainder is calculated.

  ## Options

  - `round_frames`: How to round the frame count before doing the rem operation.
    Default: `:closest`.

  - `round_remainder`: How to round the remainder frames when a non-whole frame would
    be the result. Default: `:closest`.

  ## Examples

  ```elixir
  iex> dividend = Framestamp.with_frames!("01:00:00:01", Rates.f23_98())
  iex>
  iex> result = Framestamp.rem(dividend, 4)
  iex> inspect(result)
  "<00:00:00:01 <23.98 NTSC>>"
  ```
  """
  @spec rem(
          dividend :: t(),
          divisor :: Ratio.t() | number(),
          opts :: [round_frames: round(), round_remainder: round()]
        ) :: t()
  def rem(dividend, divisor, opts \\ []) do
    with {round_frames, round_remainder} <- validate_divrem_rounding(opts) do
      %{rate: rate} = dividend

      dividend
      |> frames(round: round_frames)
      |> Ratio.new()
      |> Rational.rem(Ratio.new(divisor))
      |> Rational.round(round_remainder)
      |> with_frames!(rate)
    end
  end

  # Validates the rounding options for `divrem` and `rem`.
  @spec validate_divrem_rounding(round_frames: round(), round_remainder: round()) :: {round(), round()}
  defp validate_divrem_rounding(opts) do
    with :ok <- ensure_round_enabled(opts, :round_frames),
         :ok <- ensure_round_enabled(opts, :round_remainder) do
      round_frames = Keyword.get(opts, :round_frames, :closest)
      round_remainder = Keyword.get(opts, :round_remainder, :closest)
      {round_frames, round_remainder}
    end
  end

  @doc section: :arithmetic
  @doc """
  As the kernel `-/1` function.

  - Makes a positive `tc` value negative.
  - Makes a negative `tc` value positive.

  ## Examples

  ```elixir
  iex> stamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.minus(stamp)
  iex> inspect(result)
  "<-01:00:00:00 <23.98 NTSC>>"
  ```

  ```elixir
  iex> stamp = Framestamp.with_frames!("-01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.minus(stamp)
  iex> inspect(result)
  "<01:00:00:00 <23.98 NTSC>>"
  ```
  """
  @spec minus(t()) :: t()
  def minus(framestamp), do: %{framestamp | seconds: Ratio.minus(framestamp.seconds)}

  @doc section: :arithmetic
  @doc """
  Returns the absolute value of `tc`.

  ## Examples

  ```elixir
  iex> stamp = Framestamp.with_frames!("-01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.abs(stamp)
  iex> inspect(result)
  "<01:00:00:00 <23.98 NTSC>>"
  ```

  ```elixir
  iex> stamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.abs(stamp)
  iex> inspect(result)
  "<01:00:00:00 <23.98 NTSC>>"
  ```
  """
  @spec abs(t()) :: t()
  def abs(framestamp), do: %{framestamp | seconds: Ratio.abs(framestamp.seconds)}

  @doc section: :arithmetic
  @doc """
  Evaluates [Framestamp](`Vtc.Framestamp`) mathematical expressions in a `do` block.

  Any code captured within this macro can use Kernel operators to work with
  [Framestamp](`Vtc.Framestamp`) values instead of module functions like `add/2`.

  ## Options

  - `at`: The Framerate to cast non-[Framestamp](`Vtc.Framestamp`) values to. If this
    value is not set, then at least one value in each operation must be a
    [Framestamp](`Vtc.Framestamp`). This value can be any value accepted by
    [Framerate.new/2](`Vtc.Framerate.new/2`).

  - `ntsc`: The `ntsc` value to use when creating a new Framerate with `at`. Not needed
    if `at` is a [Framerate](`Vtc.Framerate`) value.

  ## Examples

  Use eval to do some quick math. The block captures variables from the outer scope,
  but contains the expression within its own scope, just like an `if` or `with`
  statement.

  ```elixir
  iex> require Framestamp
  iex>
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> b = Framestamp.with_frames!("00:30:00:00", Rates.f23_98())
  iex> c = Framestamp.with_frames!("00:15:00:00", Rates.f23_98())
  iex>
  iex> result =
  iex>   Framestamp.eval do
  iex>     a + b * 2 - c
  iex>   end
  iex>
  iex> inspect(result)
  "<01:45:00:00 <23.98 NTSC>>"
  ```

  Or if you want to do it in one line:

  ```elixir
  iex> require Framestamp
  iex>
  iex> a = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> b = Framestamp.with_frames!("00:30:00:00", Rates.f23_98())
  iex> c = Framestamp.with_frames!("00:15:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.eval(a + b * 2 - c)
  iex>
  iex> inspect(result)
  "<01:45:00:00 <23.98 NTSC>>"
  ```

  Just like the regular [Framestamp](`Vtc.Framestamp`) functions, only one value in an
  arithmetic expression needs to be a [Framestamp](`Vtc.Framestamp`) value. In the case
  above, since multiplication happens first, that's `b`:

  ```elixir
  iex> b = Framestamp.with_frames!("00:30:00:00", Rates.f23_98())
  iex>
  iex> result =
  iex>   Framestamp.eval do
  iex>     "01:00:00:00" + b * 2 - "00:15:00:00"
  iex>   end
  iex>
  iex> inspect(result)
  "<01:45:00:00 <23.98 NTSC>>"
  ```

  You can supply a default framerate if you just want to do some quick calculations.
  This framerate is inherited by every value that implements the
  [Frames](`Vtc.Source.Frames`) protocol in the block, including integers:

  ```elixir
  iex> result =
  iex>   Framestamp.eval at: Rates.f23_98() do
  iex>     "01:00:00:00" + "00:30:00:00" * 2 - "00:15:00:00"
  iex>   end
  iex>
  iex> inspect(result)
  "<01:45:00:00 <23.98 NTSC>>"
  ```

  You can use any value that can be parsed by `Framerate.new/2`.

  ```elixir
  iex> result =
  iex>   Framestamp.eval at: 23.98 do
  iex>     "01:00:00:00" + "00:30:00:00" * 2 - "00:15:00:00"
  iex>   end
  iex>
  iex> inspect(result)
  "<01:45:00:00 <23.98 NTSC>>"
  ```

  `ntsc: :non_drop, coerce_ntsc?: true` is assumed by default, but you can set a
  different value with the `:ntsc` option:

  ```elixir
  iex> result =
  iex>   Framestamp.eval at: 24, ntsc: nil do
  iex>     "01:00:00:00" + "00:30:00:00" * 2 - "00:15:00:00"
  iex>   end
  iex>
  iex> inspect(result)
  "<01:45:00:00 <24.0 fps>>"
  ```
  """
  @spec eval([at: Framerate.t() | number() | Ratio.t(), ntsc: Framerate.ntsc()], Macro.input()) :: Macro.t()
  defmacro eval(opts \\ [], body), do: Eval.eval(opts, body)

  @doc section: :convert
  @doc """
  Returns the number of frames that would have elapsed between `00:00:00:00` and
  [Framestamp](`Vtc.Framestamp`).

  ## Options

  - `round`: How to round the resulting frame number.

  ## What it is

  Frame number / frames count is the number of a frame if the SMPTE timecode started at
  00:00:00:00 and had been running until the current value. A SMPTE timecode of
  '00:00:00:10' has a frame number of 10. A SMPTE timecode of '01:00:00:00' has a frame
  number of 86400.

  ## Where you see it

  - Frame-sequence files: 'my_vfx_shot.0086400.exr'
  - FCP7XML cut lists:

      ```xml
      <timecode>
          <rate>
              <timebase>24</timebase>
              <ntsc>TRUE</ntsc>
          </rate>
          <string>01:00:00:00</string>
          <frame>86400</frame>  <!-- <====THIS LINE-->
          <displayformat>NDF</displayformat>
      </timecode>
      ```

  ## Examples

  ```elixir
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> Framestamp.frames(framestamp)
  86400
  ```
  """
  @spec frames(t(), opts :: [round: round()]) :: integer()
  def frames(framestamp, opts \\ []) do
    round = Keyword.get(opts, :round, :closest)

    framestamp.seconds
    |> Ratio.mult(framestamp.rate.playback)
    |> Rational.round(round)
  end

  @doc section: :convert
  @doc """
  The individual sections of a SMPTE timecode string as i64 values.

  ## Examples

  ```elixir
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.smpte_timecode_sections(framestamp)
  iex> inspect(result)
  "%Vtc.SMPTETimecode.Sections{negative?: false, hours: 1, minutes: 0, seconds: 0, frames: 0}"
  ```
  """
  @spec smpte_timecode_sections(t(), opts :: [round: round()]) :: Sections.t()
  def smpte_timecode_sections(framestamp, opts \\ []), do: Sections.from_framestamp(framestamp, opts)

  @doc section: :convert
  @doc """
  Returns the the formatted SMPTE timecode for a [Framestamp](`Vtc.Framestamp`).

  Ex: `01:00:00:00`. Drop frame timecode will be rendered with a ';' separator before the
  frames field.

  ## Options

  - `round`: How to round the resulting frames field.

  ## What it is

  Timecode is used as a human-readable way to represent the id of a given frame. It is
  formatted to give a rough sense of where to find a frame:
  `{HOURS}:{MINUTES}:{SECONDS}:{FRAME}`. For more on timecode, see Frame.io's
  [excellent post](https://blog.frame.io/2017/07/17/timecode-and-frame-rates/) on the
  subject.

  ## Where you see it

  Timecode is ubiquitous in video editing, a small sample of places you might see
  timecode:

  - Source and Playback monitors in your favorite NLE.
  - Burned into the footage for dailies.
  - Cut lists like an EDL.

  ## Examples

  ```elixir
  iex> framestamp = Framestamp.with_frames!(86_400, Rates.f23_98())
  iex> Framestamp.smpte_timecode(framestamp)
  "01:00:00:00"
  ```
  """
  @spec smpte_timecode(t(), opts :: [round: round()]) :: String.t()
  def smpte_timecode(framestamp, opts \\ []), do: framestamp |> SMPTETimecodeStr.from_framestamp(opts) |> then(& &1.in)

  @doc section: :convert
  @doc """
  Runtime Returns the true, real-world runtime of `framestamp` in `HH:MM:SS.FFFFFFFFF`
  format.

  Trailing zeroes are trimmed from the end of the return value. If the entire fractal
  seconds value would be trimmed, '.0' is used.

  ## Options

  - `precision`: The number of places to round to. Extra trailing 0's will still be
    trimmed. Default: `9`.

  - `trim_zeros?`: Whether to trim trailing zeroes. Default: `true`.

  ## What it is

  The human-readable version of `seconds`. It looks like timecode, but with a decimal
  seconds value instead of a frame number place.

  ## Where you see it

  • Anywhere real-world time is used.

  • FFMPEG commands:

    ```shell
    ffmpeg -ss 00:00:30.5 -i input.mov -t 00:00:10.25 output.mp4
    ```

  ## Note

  The true runtime will often diverge from the hours, minutes, and seconds
  value of the SMPTE timecode representation when dealing with non-whole-frame
  framerates. Even drop-frame timecode does not continuously adhere 1:1 to the
  actual runtime. For instance, <01:00:00;00 <29.97 NTSC DF>> has a true runtime of
  '00:59:59.9964', and <01:00:00:00 <23.98 NTSC>> has a true runtime of
  '01:00:03.6'

  ## Examples

  ```elixir
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> Framestamp.runtime(framestamp)
  "01:00:03.6"
  ```
  """
  @spec runtime(t(), precision: non_neg_integer(), trim_zeros?: boolean()) :: String.t()
  def runtime(framestamp, opts \\ []), do: framestamp |> RuntimeStr.from_framestamp(opts) |> then(& &1.in)

  @doc section: :convert
  @doc """
  Returns the number of elapsed ticks `framestamp` represents in Adobe Premiere Pro.

  ## Options

  - `round`: How to round the resulting ticks.

  ## What it is

  Internally, Adobe Premiere Pro uses ticks to divide up a second, and keep track of how
  far into that second we are. There are 254016000000 ticks in a second, regardless of
  framerate in Premiere.

  ## Where you see it

  - Premiere Pro Panel functions and scripts.

  - FCP7XML cutlists generated from Premiere:

    ```xml
    <clipitem id="clipitem-1">
    ...
    <in>158</in>
    <out>1102</out>
    <pproTicksIn>1673944272000</pproTicksIn>
    <pproTicksOut>11675231568000</pproTicksOut>
    ...
    </clipitem>
    ```

  ## Examples

  ```elixir
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex> Framestamp.premiere_ticks(framestamp)
  915372057600000
  ```
  """
  @spec premiere_ticks(t(), opts :: [round: round()]) :: integer()
  def premiere_ticks(framestamp, opts \\ []) do
    with :ok <- ensure_round_enabled(opts) do
      framestamp |> PremiereTicks.from_framestamp(opts) |> then(& &1.in)
    end
  end

  @doc section: :convert
  @doc """
  Returns the number of physical film feet and frames `framestamp` represents if shot
  on film.

  Ex: '5400+13'.

  ## Options

  - `round`: How to round the internal frame count before conversion. Default: `:closest`.

  - `film_format`: The film format to use when doing the calculation. For more on film
    formats, see `Vtc.FilmFormat`. Default: `:ff35mm_4perf`, by far the most common
    format used to shoot Hollywood movies.

  ## What it is

  On physical film, each foot contains a certain number of frames. For 35mm, 4-perf film
  (the most common type on Hollywood movies), this number is 16 frames per foot.
  Feet-And-Frames was often used in place of Keycode to quickly reference a frame in the
  edit.

  ## Where you see it

  For the most part, feet + frames has died out as a reference, because digital media is
  not measured in feet. The most common place it is still used is Studio Sound
  Departments. Many Sound Mixers and Designers intuitively think in feet + frames, and
  it is often burned into the reference picture for them.

  - Telecine.
  - Sound turnover reference picture.
  - Sound turnover change lists.

  For more information on individual film formats, see the `Vtc.FilmFormat` module.

  ## Examples

  Defaults to 35mm, 4perf:

  ```elixir
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.feet_and_frames(framestamp)
  iex> inspect(result)
  "<5400+00 :ff35mm_4perf>"
  ```

  Use `String.Chars` to convert the resulting struct to a traditional F=F string:

  ```elixir
  iex> alias Vtc.Source.Frames.FeetAndFrames
  iex>
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.feet_and_frames(framestamp)
  iex> String.Chars.to_string(result)
  "5400+00"
  ```

  Outputting as a different film format:

  ## Examples

  ```elixir
  iex> framestamp = Framestamp.with_frames!("01:00:00:00", Rates.f23_98())
  iex>
  iex> result = Framestamp.feet_and_frames(framestamp, film_format: :ff16mm)
  iex> inspect(result)
  "<4320+00 :ff16mm>"
  ```
  """
  @spec feet_and_frames(t(), opts :: [film_format: FilmFormat.t(), round: round()]) :: FeetAndFrames.t()
  def feet_and_frames(framestamp, opts \\ []) do
    with :ok <- ensure_round_enabled(opts) do
      FeetAndFrames.from_framestamp(framestamp, opts)
    end
  end

  # Ensures that rounding is enabled for functions that cannot meaningfully turn
  # rounding off, such as those that must return an integer.
  @spec ensure_round_enabled(Keyword.t(), atom()) :: :ok
  defp ensure_round_enabled(opts, opt \\ :round)

  defp ensure_round_enabled(opts, opt) do
    case Keyword.get(opts, opt, :closest) do
      :off -> raise(ArgumentError.exception("`:#{opt}` cannot be `:off`"))
      _ -> :ok
    end
  end

  @spec handle_raise_function({:ok, t()} | {:error, Exception.t()}) :: t()
  defp handle_raise_function({:ok, result}), do: result
  defp handle_raise_function({:error, error}), do: raise(error)

  when_pg_enabled do
    use Ecto.Type

    alias Ecto.Changeset

    @doc section: :ecto_migrations
    @doc """
    The database type for [PgFramestamp](`Vtc.Ecto.Postgres.PgFramestamp`).

    Can be used in migrations as the fields type.
    """
    @impl Ecto.Type
    @spec type() :: atom()
    defdelegate type, to: PgFramestamp

    @impl Ecto.Type
    @spec cast(t() | %{String.t() => any()} | %{atom() => any()}) :: {:ok, t()} | :error
    defdelegate cast(value), to: PgFramestamp

    @impl Ecto.Type
    @spec load(PgFramestamp.db_record()) :: {:ok, t()} | :error
    defdelegate load(value), to: PgFramestamp

    @impl Ecto.Type
    @spec dump(t()) :: {:ok, PgFramestamp.db_record()} | :error
    defdelegate dump(value), to: PgFramestamp

    @doc section: :changeset_validators
    @doc """
    Adds all constraints created by
    [PgFramestamp.Migrations.create_constraints/3](`Vtc.Ecto.Postgres.PgFramestamp.Migrations.create_constraints/3`)
    to changeset to be added as changeset errors rather than raised.

    ## Options

    Pass the same options that were passed to
    [PgFramestamp.Migrations.create_constraints/3](`Vtc.Ecto.Postgres.PgFramestamp.Migrations.create_constraints/3`)
    """
    @spec validate_constraints(
            Changeset.t(data),
            atom(),
            [PgFramestamp.Migrations.constraint_opt()]
          ) :: Changeset.t(data)
          when data: any()
    defdelegate validate_constraints(changeset, field, opts \\ []), to: PgFramestamp
  end
end

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

  @spec inspect(Framestamp.t(), Elixir.Inspect.Opts.t()) :: String.t()
  def inspect(framestamp, _opts) do
    "<#{Framestamp.smpte_timecode(framestamp)} #{inspect(framestamp.rate)}>"
  end
end

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

  @spec to_string(Framestamp.t()) :: String.t()
  def to_string(framestamp), do: inspect(framestamp)
end