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