From 7cd6bdb1e0e8f5bc503ef122d8adc6b3f5650009 Mon Sep 17 00:00:00 2001 From: Billy Peake Date: Mon, 6 Mar 2023 03:00:16 -0800 Subject: [PATCH] Add Timecode.rebase (#13) Timecode.rebase and other misc friends --- .credo.exs | 210 +++++++++++++++++++++++++++ .vscode/settings.json | 13 +- Makefile | 1 + lib/consts.ex | 16 +-- lib/drop_frame.ex | 2 +- lib/framerate.ex | 25 ++-- lib/sources.ex | 104 +++++++------- lib/timcode.ex | 319 ++++++++++++++++++++++------------------- test/timecode_test.exs | 236 ++++++++++++++++++++++-------- 9 files changed, 641 insertions(+), 285 deletions(-) create mode 100644 .credo.exs diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..9965c2f --- /dev/null +++ b/.credo.exs @@ -0,0 +1,210 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.WrongTestFileExtension, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 4f514b9..6bb6613 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,16 +2,15 @@ "emeraldwalk.runonsave": { "commands": [ { - "match": "*.ex", - "cmd": "mix format ${relativeFile}" - }, - { - "match": "*.exs", - "cmd": "mix format ${relativeFile}" + "match": "\\.exs$|\\.ex$", + "cmd": "mix format ${file}" } ] }, - "elixir.credo.credoConfiguration": "config/.credo.exs", + "elixirLinter.useStrict": true, + "elixir.credo.executePath": "/Users/bpeake/.asdf/shims/mix", + "elixir.credo.credoConfiguration": "/Users/bpeake/code/opencinema/vtc-ex/.credo.exs", + "elixir.credo.strictMode": true, "githubPullRequests.ignoredPullRequestBranches": [ "qa" ] diff --git a/Makefile b/Makefile index d1e4366..1e9e7f6 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ test: lint: -mix format --check-formatted -mix dialyzer + -mix credo --strict -find . -type f | grep -e "\.ex$$" -e "\.exs$$" | grep -v zdevelop/ | grep -v _build | grep -v deps | xargs misspell -error .PHONY: format diff --git a/lib/consts.ex b/lib/consts.ex index abb4c0f..9d5ab7a 100644 --- a/lib/consts.ex +++ b/lib/consts.ex @@ -1,15 +1,15 @@ defmodule Vtc.Private.Consts do @moduledoc false - @spec seconds_per_minute() :: integer - def seconds_per_minute(), do: 60 + @spec seconds_per_minute() :: integer() + def seconds_per_minute, do: 60 - @spec seconds_per_hour() :: integer - def seconds_per_hour(), do: seconds_per_minute() * 60 + @spec seconds_per_hour() :: integer() + def seconds_per_hour, do: seconds_per_minute() * 60 - @spec ppro_tick_per_second() :: integer - def ppro_tick_per_second(), do: 254_016_000_000 + @spec ppro_tick_per_second() :: integer() + def ppro_tick_per_second, do: 254_016_000_000 - @spec frames_per_foot() :: integer - def frames_per_foot(), do: 16 + @spec frames_per_foot() :: integer() + def frames_per_foot, do: 16 end diff --git a/lib/drop_frame.ex b/lib/drop_frame.ex index 6c0d5e8..746e97e 100644 --- a/lib/drop_frame.ex +++ b/lib/drop_frame.ex @@ -1,8 +1,8 @@ defmodule Vtc.Private.DropFrame do @moduledoc false alias Vtc.Framerate - alias Vtc.Utils.Rational alias Vtc.Timecode + alias Vtc.Utils.Rational # Adjusts the frame number based on drop-frame TC conventions. # diff --git a/lib/framerate.ex b/lib/framerate.ex index 354c77a..b0ecd4f 100644 --- a/lib/framerate.ex +++ b/lib/framerate.ex @@ -12,7 +12,7 @@ defmodule Vtc.Framerate do @typedoc """ Enum of `Ntsc` types. - # Values + ## Values - `nil`: Not an NTSC value - `:non_drop` A non-drop NTSC value. @@ -27,12 +27,12 @@ defmodule Vtc.Framerate do @typedoc """ Type of `Framerate` - # Fields + ## Fields - - `:playback`: The rational representation of the real-world playback speed as a + - **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. + - **ntsc**: Atom representing which, if any, NTSC convention this framerate adheres to. """ @type t :: %__MODULE__{playback: Rational.t(), ntsc: ntsc()} @@ -53,9 +53,9 @@ defmodule Vtc.Framerate do @typedoc """ Type of `ParseError` - # Fields + ## Fields - - `:reason`: The reason the error occurred must be one of the following: + - **reason**: The reason the error occurred must be one of the following: - `:bad_drop_rate`: Returned when the playback speed of a framerate with an ntsc value of :drop is not divisible by 3000/1001 (29.97), for more on why drop-frame @@ -93,16 +93,14 @@ defmodule Vtc.Framerate do end @typedoc """ - Type returned by `Framerate.new/2` - - `Framerate.new!/2` raises the error value instead. + 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 + ## Arguments - **rate**: Either the playback rate or timebase. For NTSC framerates, the value will be rounded to the nearest correct value. @@ -151,7 +149,7 @@ defmodule Vtc.Framerate do end @doc """ - As `Framerate.new/2` but raises an error instead. + 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 @@ -221,9 +219,8 @@ defmodule Vtc.Framerate do end @doc """ - Returns true if the value represents and NTSC framerate. - - So will return true on `:non_drop` and `:drop`. + 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?(%__MODULE__{ntsc: nil}), do: false diff --git a/lib/sources.ex b/lib/sources.ex index 67cc274..4f610e2 100644 --- a/lib/sources.ex +++ b/lib/sources.ex @@ -5,18 +5,10 @@ defmodule Vtc.Source do alias Vtc.Timecode alias Vtc.Utils.Rational - @typedoc """ - Result type of `Source.Seconds.seconds/2`. - """ - @type seconds_result() :: {:ok, Rational.t()} | {:error, Timecode.ParseError.t()} - defprotocol Seconds do - alias Vtc.Framerate - alias Vtc.Source - @moduledoc """ Protocol which types can implement to be passed as the main value of - `with_seconds/2`. + `Timecode.with_seconds/2`. ## Implementations @@ -25,15 +17,24 @@ defmodule Vtc.Source do - `Ratio` - `Integer` - `Float` - - `String` & 'BitString' + - `String` - runtime ("01:00:00.0") - decimal ("3600.0") """ + alias Vtc.Framerate + alias Vtc.Source + alias Vtc.Timecode + + @typedoc """ + Result type of `Source.Seconds.seconds/2`. + """ + @type result() :: {:ok, Rational.t()} | {:error, Timecode.ParseError.t()} + @doc """ Returns the value as a rational seconds value. - # Arguments + ## Arguments - **value**: The source value. @@ -44,7 +45,7 @@ defmodule Vtc.Source do A result tuple with a rational representation of the seconds value using `Ratio` on success. """ - @spec seconds(t(), Framerate.t()) :: Source.seconds_result() + @spec seconds(t(), Framerate.t()) :: result() def seconds(value, rate) end @@ -54,7 +55,7 @@ defmodule Vtc.Source do alias Vtc.Source alias Vtc.Utils.Rational - @spec seconds(Rational.t(), Framerate.t()) :: Source.seconds_result() + @spec seconds(Rational.t(), Framerate.t()) :: Seconds.result() def seconds(value, rate), do: Parse.from_seconds_core(value, rate) end @@ -62,7 +63,7 @@ defmodule Vtc.Source do alias Vtc.Framerate alias Vtc.Source - @spec seconds(float(), Framerate.t()) :: Source.seconds_result() + @spec seconds(float(), Framerate.t()) :: Seconds.result() def seconds(value, rate), do: value |> Ratio.new(1) |> Seconds.seconds(rate) end @@ -71,21 +72,16 @@ defmodule Vtc.Source do alias Vtc.Private.Parse alias Vtc.Source - @spec seconds(String.t(), Framerate.t()) :: Source.seconds_result() + @spec seconds(String.t(), Framerate.t()) :: Seconds.result() def seconds(value, rate), do: Parse.parse_runtime_string(value, rate) end - @typedoc """ - Result type of `Vtc.Source.Frames.frames/2`. - """ - @type frames_result() :: {:ok, integer()} | {:error, Timecode.ParseError.t()} - defprotocol Frames do @moduledoc """ Protocol which types can implement to be passed as the main value of - `Vtc.Timecode.with_frames/2`. + `Timecode.with_frames/2`. - # Implementations + ## Implementations Out of the box, this protocol is implemented for the following types: @@ -96,6 +92,15 @@ defmodule Vtc.Source do - Feet+Frames ("5400+00") """ + alias Vtc.Framerate + alias Vtc.Source + alias Vtc.Timecode + + @typedoc """ + Result type of `Vtc.Source.Frames.frames/2 aaa`. + """ + @type result() :: {:ok, integer()} | {:error, Timecode.ParseError.t()} + @doc """ Returns the value as a frame count. @@ -110,35 +115,27 @@ defmodule Vtc.Source do A result tuple with an integer value representing the frame count on success. """ - alias Vtc.Framerate - alias Vtc.Source - - @spec frames(t(), Framerate.t()) :: Source.frames_result() + @spec frames(t(), Framerate.t()) :: result() def frames(value, rate) end defimpl Frames, for: Integer do alias Vtc.Framerate - alias Vtc.Source + alias Vtc.Source.Frames - @spec frames(integer(), Framerate.t()) :: Source.frames_result() + @spec frames(integer(), Framerate.t()) :: Frames.result() def frames(value, _rate), do: {:ok, value} end defimpl Frames, for: [String, BitString] do alias Vtc.Framerate alias Vtc.Private.Parse - alias Vtc.Source + alias Vtc.Source.Frames - @spec frames(String.t(), Framerate.t()) :: Source.frames_result() + @spec frames(String.t(), Framerate.t()) :: Frames.result() def frames(value, rate), do: Parse.parse_frames_string(value, rate) end - @typedoc """ - Result type of `Vtc.Source.PremiereTicks.ticks/2`. - """ - @type ticks_result() :: {:ok, integer()} | {:error, Timecode.ParseError.t()} - defprotocol PremiereTicks do @moduledoc """ Protocol which types can implement to be passed as the main value of @@ -168,8 +165,14 @@ defmodule Vtc.Source do alias Vtc.Framerate alias Vtc.Source + alias Vtc.Timecode - @spec ticks(t(), Framerate.t()) :: Source.ticks_result() + @typedoc """ + Result type of `ticks/2`. + """ + @type result() :: {:ok, integer()} | {:error, Timecode.ParseError.t()} + + @spec ticks(t(), Framerate.t()) :: result() def ticks(value, rate) end @@ -177,7 +180,7 @@ defmodule Vtc.Source do alias Vtc.Framerate alias Vtc.Source - @spec ticks(integer(), Framerate.t()) :: Source.ticks_result() + @spec ticks(integer(), Framerate.t()) :: PremiereTicks.result() def ticks(value, _rate), do: {:ok, value} end end @@ -190,24 +193,27 @@ defmodule Vtc.Private.Parse do alias Vtc.Framerate alias Vtc.Private.Consts alias Vtc.Private.DropFrame - alias Vtc.Utils.Rational alias Vtc.Source alias Vtc.Source.Frames + alias Vtc.Source.Seconds alias Vtc.Timecode + alias Vtc.Utils.Rational - @spec from_seconds_core(Rational.t(), Framerate.t()) :: Source.seconds_result() - def from_seconds_core(value, rate) do - case Ratio.div(value, rate.playback) do + @spec from_seconds_core(Rational.t(), Framerate.t()) :: Seconds.result() + def from_seconds_core(input, rate) do + # If the vaue doesn't cleany divide into the framerate then we need to round to the + # nearest frame. + case Ratio.div(input, rate.playback) do %Ratio{} -> - frames = rate.playback |> Ratio.mult(value) |> Rational.round() + frames = rate.playback |> Ratio.mult(input) |> Rational.round() {:ok, Ratio.div(frames, rate.playback)} - integer_value -> - {:ok, integer_value} + _ -> + {:ok, input} end end - @spec parse_frames_string(String.t(), Framerate.t()) :: Source.frames_result() + @spec parse_frames_string(String.t(), Framerate.t()) :: Frames.result() def parse_frames_string(value, rate) do case parse_tc_string(value, rate) do {:ok, _} = result -> result @@ -218,7 +224,7 @@ defmodule Vtc.Private.Parse do @tc_regex ~r/^(?P-)?((?P[0-9]+)[:|;])?((?P[0-9]+)[:|;])?((?P[0-9]+)[:|;])?(?P[0-9]+)$/ - @spec parse_tc_string(String.t(), Framerate.t()) :: Source.frames_result() + @spec parse_tc_string(String.t(), Framerate.t()) :: Frames.result() def parse_tc_string(value, rate) do with {:ok, matched} <- apply_regex(@tc_regex, value) do matched @@ -285,7 +291,7 @@ defmodule Vtc.Private.Parse do defp pop_time_section([]), do: {0, []} # Converts all TC fields to a total frame count - @spec tc_sections_to_frames(Timecode.Sections.t(), Framerate.t()) :: Source.frames_result() + @spec tc_sections_to_frames(Timecode.Sections.t(), Framerate.t()) :: Frames.result() defp tc_sections_to_frames(sections, rate) do with {:ok, adjustment} <- DropFrame.parse_adjustment(sections, rate) do frames_per_second = Framerate.timebase(rate) @@ -304,7 +310,7 @@ defmodule Vtc.Private.Parse do @ff_regex ~r/(?P-)?(?P[0-9]+)\+(?P[0-9]+)/ - @spec parse_feet_and_frames(String.t(), Framerate.t()) :: Source.frames_result() + @spec parse_feet_and_frames(String.t(), Framerate.t()) :: Frames.result() defp parse_feet_and_frames(value, rate) do with {:ok, groups} <- apply_regex(@ff_regex, value) do negative? = Map.fetch!(groups, "negative") == "-" @@ -321,7 +327,7 @@ defmodule Vtc.Private.Parse do @runtime_regex ~r/^(?P-)?((?P[0-9]+)[:|;])?((?P[0-9]+)[:|;])?(?P[0-9]+(\.[0-9]+)?)$/ - @spec parse_runtime_string(String.t(), Framerate.t()) :: Source.seconds_result() + @spec parse_runtime_string(String.t(), Framerate.t()) :: Seconds.result() def parse_runtime_string(value, rate) do with {:ok, matched} <- apply_regex(@runtime_regex, value) do matched diff --git a/lib/timcode.ex b/lib/timcode.ex index 6f10b3a..b6dd112 100644 --- a/lib/timcode.ex +++ b/lib/timcode.ex @@ -8,10 +8,10 @@ defmodule Vtc.Timecode do alias Vtc.Framerate alias Vtc.Private.Consts alias Vtc.Private.DropFrame - alias Vtc.Utils.Rational alias Vtc.Source.Frames alias Vtc.Source.PremiereTicks alias Vtc.Source.Seconds + alias Vtc.Utils.Rational @enforce_keys [:seconds, :rate] defstruct [:seconds, :rate] @@ -19,13 +19,13 @@ defmodule Vtc.Timecode do @typedoc """ `Timecode` type. - # Fields + ## Fields - - **:seconds**: The real-world seconds elapsed since 01:00:00:00 as a rational value. + - **seconds**: The real-world seconds elapsed since 01:00:00:00 as a rational value. (Note: The Ratio module automatically will coerce itself to an integer whenever possible, so this value may be an integer when exactly a whole-second value). - - **:rate**: the Framerate of the timecode. + - **rate**: the Framerate of the timecode. """ @type t :: %__MODULE__{ seconds: Rational.t(), @@ -46,9 +46,13 @@ defmodule Vtc.Timecode do ## Fields - **negative**: Whether the timecode is less than 0. + - **hours**: Hours place value. + - **minutes**: Minutes place value. + - **seconds**: Seconds place value. + - **frames**: Frames place value. """ @type t :: %__MODULE__{ @@ -60,8 +64,155 @@ defmodule Vtc.Timecode do } end + defmodule ParseError do + @moduledoc """ + Exception returned when there is an error parsing a Timecode value. + """ + defexception [:reason] + + @typedoc """ + Type of `Timecode.ParseError` + + ## Fields + + - **reason**: The reason the error occurred must be one of the following: + + - `:unrecognized_format`: Returned when a string value is not a recognized + timecode, runtime, etc. format. + + - `:bad_drop_frames`: The field value cannot exist in properly formatted + drop-frame timecode. + """ + @type t :: %ParseError{reason: :unrecognized_format | :bad_drop_frames} + + @doc """ + Returns a message for the error reason. + """ + @spec message(t()) :: String.t() + def message(%__MODULE__{reason: :unrecognized_format}), + do: "string format not recognized" + + def message(%__MODULE__{reason: :bad_drop_frames}), + do: "frames value not allowed for drop-frame timecode. frame should have been dropped" + end + + @typedoc """ + Type returned by `with_seconds/2` and `with_frames/2`. + """ + @type parse_result() :: {:ok, t()} | {:error, ParseError.t()} + + @doc """ + Returns a new `Timecode` with a Timecode.seconds field value equal to the + seconds arg. + + ## Arguments + + - **seconds**: A value which can be represented as a number of seconds. Must implement + the `Seconds` protocol. + + - **rate**: Frame-per-second playback value of the timecode. + """ + @spec with_seconds(Seconds.t(), Framerate.t()) :: parse_result() + def with_seconds(seconds, rate) do + with {:ok, seconds} <- Seconds.seconds(seconds, rate) do + {:ok, %__MODULE__{seconds: seconds, rate: rate}} + end + end + + @doc """ + As `with_seconds/2`, but raises on error. + """ + @spec with_seconds!(Seconds.t(), Framerate.t()) :: t() + def with_seconds!(seconds, rate) do + seconds + |> with_seconds(rate) + |> handle_raise_function() + end + + @doc """ + Returns a new `Timecode` with a `frames/1` 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` protocol. + + - **rate**: Frame-per-second playback value of the timecode. + """ + @spec with_frames(Frames.t(), Framerate.t()) :: parse_result() + def with_frames(frames, rate) do + with {:ok, frames} <- Frames.frames(frames, rate) do + frames + |> Ratio.div(rate.playback) + |> with_seconds(rate) + end + end + + @doc """ + As `Timecode.with_frames/2`, 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 + + @doc """ + Returns a new `Timecode` with a `premiere_ticks/1` return value equal + to the ticks arg. + + ## Arguments + + - **ticks**: Any value that can represent the number of ticks for a given timecode. + Must implement the `PremiereTicks` protocol. + + - **rate**: Frame-per-second playback value of the timecode. + """ + @spec with_premiere_ticks(PremiereTicks.t(), Framerate.t()) :: parse_result() + def with_premiere_ticks(ticks, rate) do + with {:ok, ticks} <- PremiereTicks.ticks(ticks, rate) do + seconds = ticks / Consts.ppro_tick_per_second() + with_seconds(seconds, rate) + end + end + + @doc """ + As `with_premiere_ticks/2`, but raises on error. + """ + @spec with_premiere_ticks!(Frames.t(), Framerate.t()) :: t() + def with_premiere_ticks!(ticks, rate) do + ticks + |> with_premiere_ticks(rate) + |> handle_raise_function() + end + + @doc """ + Rebases the timecode 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 `timecode.rate`. + + ## Examples + + ```elixir + iex> timecode = Timecode.with_frames!("01:00:00:00", Rates.f23_98()) + iex> {:ok, rebased} = Timecode.rebase(timecode, Rates.f47_95()) + iex> Timecode.to_string(rebased) + "<00:30:00:00 @ <47.95 NTSC NDF>>" + ``` + """ + @spec rebase(t(), Framerate.t()) :: parse_result() + def rebase(timecode, new_rate), do: timecode |> frames() |> with_frames(new_rate) + + @doc """ + As `rebase/2`, but raises on error. + """ + @spec rebase!(t(), Framerate.t()) :: t() + def rebase!(timecode, new_rate), do: timecode |> frames() |> with_frames!(new_rate) + @doc """ - Returns whether a is greater than, equal to, or less than b in terms of real-world + Returns whether `a` is greater than, equal to, or less than `b` in terms of real-world seconds. b May be any value that implements the `Frames` protocol, such as a timecode string, @@ -74,16 +225,16 @@ defmodule Vtc.Timecode do represents more real-world time. ```elixir - a = Timecode.new("01:00:00:00", Rates.f23_98()) - b = Timecode.new("01:00:00:00", Rates.f24()) - - :gt = Timecode.compare(a, b) + iex> a = Timecode.with_frames!("01:00:00:00", Rates.f23_98()) + iex> b = Timecode.with_frames!("01:00:00:00", Rates.f24()) + iex> :gt = Timecode.compare(a, b) ``` Using a timcode and a bare string: ```elixir - :eq = "01:00:00:00" |> Timecode.new(Rates.f23_98()) |> Timecode.compare("01:00:00:00") + iex> timecode = Timecode.with_frames!("01:00:00:00", Rates.f23_98()) + iex> :eq = Timecode.compare(timecode, "01:00:00:00") ``` """ @spec compare(t(), t() | Frames.t()) :: :lt | :eq | :gt @@ -94,13 +245,13 @@ defmodule Vtc.Timecode do Returns the number of frames that would have elapsed between 00:00:00:00 and this timecode. - # What it is + ## What it is Frame number / frames count is the number of a frame if the timecode started at 00:00:00:00 and had been running until the current value. A timecode of '00:00:00:10' has a frame number of 10. A timecode of '01:00:00:00' has a frame number of 86400. - # Where you see it + ## Where you see it - Frame-sequence files: 'my_vfx_shot.0086400.exr' - FCP7XML cut lists: @@ -157,14 +308,14 @@ defmodule Vtc.Timecode do Returns the the formatted SMPTE timecode: (ex: 01:00:00:00). Drop frame timecode will be rendered with a ';' sperator before the frames field. - # What it is + ## 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 + ## Where you see it Timecode is ubiquitous in video editing, a small sample of places you might see timecode: @@ -205,12 +356,12 @@ defmodule Vtc.Timecode do - `precision`: The number of places to round to. Extra trailing 0's will still be trimmed. - # What it is + ## What it is The formatted version of seconds. It looks like timecode, but with a decimal seconds value instead of a frame number place. - # Where you see it + ## Where you see it • Anywhere real-world time is used. @@ -220,7 +371,7 @@ defmodule Vtc.Timecode do ffmpeg -ss 00:00:30.5 -i input.mov -t 00:00:10.25 output.mp4 ``` - # Note + ## Note The true runtime will often diverge from the hours, minutes, and seconds value of the timecode representation when dealing with non-whole-frame @@ -279,13 +430,13 @@ defmodule Vtc.Timecode do @doc """ Returns the number of elapsed ticks this timecode represents in Adobe Premiere Pro. - # What it is + ## 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 + ## Where you see it - Premiere Pro Panel functions and scripts. @@ -310,14 +461,14 @@ defmodule Vtc.Timecode do Returns the number of feet and frames this timecode represents if it were shot on 35mm 4-perf film (16 frames per foot). ex: '5400+13'. - # What it is + ## 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 + ## 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 @@ -347,130 +498,6 @@ defmodule Vtc.Timecode do "#{sign}#{feet}+#{frames}" end - defmodule ParseError do - @moduledoc """ - Exception returned when there is an error parsing a Timecode value. - """ - defexception [:reason] - - @typedoc """ - Type of `Timecode.ParseError` - - # Fields - - - `:reason`: The reason the error occurred must be one of the following: - - - `:unrecognized_format`: Returned when a string value is not a recognized - timecode, runtime, etc. format. - """ - @type t :: %ParseError{reason: :unrecognized_format | :bad_drop_frames} - - @doc """ - Returns a message for the error reason. - """ - @spec message(t()) :: String.t() - def message(%__MODULE__{reason: :unrecognized_format}), - do: "string format not recognized" - - def message(%__MODULE__{reason: :bad_drop_frames}), - do: "frames value not allowed for drop-frame timecode. frame should have been dropped" - end - - @typedoc """ - Type returned by `Timecode.with_seconds/2` and `Timecode.with_frames/2`. - """ - @type parse_result() :: {:ok, t()} | {:error, ParseError.t()} - - @doc """ - Returns a new `Timecode` with a Timecode.seconds field value equal to the - seconds arg. - - Timecode::with_frames takes many different formats (more than just numeric types) that - represent the frame count of the timecode. - - # Arguments - - - `seconds` - A value which can be represented as a number of seconds. - - `rate` - The Framerate at which the frames are being played back. - """ - @spec with_seconds(Seconds.t(), Framerate.t()) :: parse_result - def with_seconds(seconds, rate) do - with {:ok, seconds} <- Seconds.seconds(seconds, rate) do - {:ok, %__MODULE__{seconds: seconds, rate: rate}} - end - end - - @doc """ - As `Timecode.with_seconds/2`, but raises on error. - """ - @spec with_seconds!(Seconds.t(), Framerate.t()) :: t() - def with_seconds!(seconds, rate) do - seconds - |> with_seconds(rate) - |> handle_raise_function() - end - - @doc """ - Returns a new `Timecode` with a `Timecode.frames/1` return value equal to the - frames arg. - - with_frames takes many different formats (more than just numeric types) that - represent the frame count of the timecode. - - # Arguments - - - `frames` - A value which can be represented as a frame number / frame count. - - `rate` - The Framerate at which the frames are being played back. - """ - @spec with_frames(Frames.t(), Framerate.t()) :: parse_result() - def with_frames(frames, rate) do - with {:ok, frames} <- Frames.frames(frames, rate) do - frames - |> Ratio.div(rate.playback) - |> with_seconds(rate) - end - end - - @doc """ - As `Timecode.with_frames/2`, 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 - - @doc """ - Returns a new `Timecode` with a `Timecode.premiere_ticks/1` return value equal - to the ticks arg. - - with_premiere_ticks takes many different formats (more than just numeric types) that - can represent the tick count of the timecode. - - # Arguments - - - `frames` - A value which can be represented as a frame number / frame count. - - `rate` - The Framerate at which the frames are being played back. - """ - @spec with_premiere_ticks(PremiereTicks.t(), Framerate.t()) :: parse_result() - def with_premiere_ticks(ticks, rate) do - with {:ok, ticks} <- PremiereTicks.ticks(ticks, rate) do - seconds = ticks / Consts.ppro_tick_per_second() - with_seconds(seconds, rate) - end - end - - @doc """ - As `Timecode.with_premiere_ticks/2`, but raises on error. - """ - @spec with_premiere_ticks!(Frames.t(), Framerate.t()) :: t() - def with_premiere_ticks!(ticks, rate) do - ticks - |> with_premiere_ticks(rate) - |> handle_raise_function() - end - @spec to_string(t()) :: String.t() def to_string(tc) do tc_str = timecode(tc) @@ -491,6 +518,8 @@ defimpl Inspect, for: Vtc.Timecode do def inspect(tc, _opts), do: Timecode.to_string(tc) end +# opportunities + defimpl String.Chars, for: Vtc.Timecode do alias Vtc.Timecode diff --git a/test/timecode_test.exs b/test/timecode_test.exs index 10251e0..ed0da2a 100644 --- a/test/timecode_test.exs +++ b/test/timecode_test.exs @@ -1,6 +1,8 @@ defmodule Vtc.TimecodeTest.TcParseCase do - alias Vtc.Sources.Seconds + @moduledoc false + alias Vtc.Sources.Frames + alias Vtc.Sources.Seconds defstruct [ :name, @@ -30,6 +32,8 @@ defmodule Vtc.TimecodeTest.TcParseCase do end defmodule Vtc.TimecodeTest.ParseHelpers do + @moduledoc false + alias Vtc.Timecode alias Vtc.TimecodeTest.TcParseCase @@ -53,40 +57,30 @@ defmodule Vtc.TimecodeTest.ParseHelpers do def make_negative_input(input), do: Ratio.negate(input) end -defmodule Vtc.TimecodeTest.MalformedCase do +defmodule Vtc.TimecodeTest do @moduledoc false - # Holds information for testing malformed timecode strings. - - defstruct [ - :val_in, - :expected - ] - - @type t :: %__MODULE__{val_in: String.t(), expected: String.t()} -end - -defmodule Vtc.TimecodeTest do use ExUnit.Case use ExUnitProperties - alias Vtc.Rates alias Vtc.Framerate + alias Vtc.Rates alias Vtc.Timecode alias Vtc.TimecodeTest.ParseHelpers alias Vtc.TimecodeTest.TcParseCase - alias Vtc.TimecodeTest.MalformedCase + + doctest Vtc.Timecode @parse_cases [ # 23.98 NTSC ######################### ###################################### %TcParseCase{ name: "01:00:00:00 @ 23.98 NTSC", - rate: Framerate.new!(23.98, :non_drop), + rate: Rates.f23_98(), seconds_inputs: [ Ratio.new(18_018, 5), - 3_603.6, + 3603.6, "01:00:03.6" ], frames_inputs: [ @@ -103,10 +97,10 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:40:00:00 @ 23.98 NTSC", - rate: Framerate.new!(23.98, :non_drop), + rate: Rates.f23_98(), seconds_inputs: [ Ratio.new(12_012, 5), - 2_402.4, + 2402.4, "00:40:02.4" ], frames_inputs: [ @@ -121,11 +115,32 @@ defmodule Vtc.TimecodeTest do premiere_ticks: 610_248_038_400_000, feet_and_frames: "3600+00" }, + # 24 True ############################ + ###################################### + %TcParseCase{ + name: "01:00:00:00 @ 24", + rate: Rates.f24(), + seconds_inputs: [ + 3600, + "01:00:00.0" + ], + frames_inputs: [ + 86_400, + "01:00:00:00", + "5400+00" + ], + seconds: Ratio.new(3600, 1), + frames: 86_400, + timecode: "01:00:00:00", + runtime: "01:00:00.0", + premiere_ticks: 914_457_600_000_000, + feet_and_frames: "5400+00" + }, # 29.97 Drop ######################### ###################################### %TcParseCase{ name: "00:00:00;00 29.97 Drop-Frame", - rate: Framerate.new!(29.97, :drop), + rate: Rates.f29_97_df(), seconds_inputs: [ 0, 0.0, @@ -145,7 +160,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:00:02;02 29.97 Drop-Frame", - rate: Framerate.new!(29.97, :drop), + rate: Rates.f29_97_df(), seconds_inputs: [ Ratio.new(31_031, 15_000), 2.068733333333333333333333333, @@ -165,7 +180,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:01:00;02 29.97 Drop-Frame", - rate: Framerate.new!(29.97, :drop), + rate: Rates.f29_97_df(), seconds_inputs: [ Ratio.new(3003, 50), 60.06, @@ -185,7 +200,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:2:00;02 29.97 Drop-Frame", - rate: Framerate.new!(29.97, :drop), + rate: Rates.f29_97_df(), seconds_inputs: [ Ratio.new(1_800_799, 15_000), 120.0532666666666666666666667, @@ -205,7 +220,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:10:00;00 29.97 Drop-Frame", - rate: Framerate.new!(29.97, :drop), + rate: Rates.f29_97_df(), seconds_inputs: [ Ratio.new(2_999_997, 5000), 599.9994, @@ -225,7 +240,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:11:00;02 29.97 Drop-Frame", - rate: Framerate.new!(29.97, :drop), + rate: Rates.f29_97_df(), seconds_inputs: [ Ratio.new(3_300_297, 5000), 660.0594, @@ -245,7 +260,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "01:00:00;00 29.97 Drop-Frame", - rate: Framerate.new!(29.97, :drop), + rate: Rates.f29_97_df(), seconds_inputs: [ Ratio.new(8_999_991, 2500), 3599.9964, @@ -267,7 +282,7 @@ defmodule Vtc.TimecodeTest do ###################################### %TcParseCase{ name: "00:00:00;00 59.94 Drop-Frame", - rate: Framerate.new!(59.94, :drop), + rate: Rates.f59_94_df(), seconds_inputs: [ Ratio.new(0, 1), 0.0, @@ -287,7 +302,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:00:01;01 59.94 Drop-Frame", - rate: Framerate.new!(59.94, :drop), + rate: Rates.f59_94_df(), seconds_inputs: [ Ratio.new(61_061, 60_000), 1.017683333333333333333333333, @@ -307,7 +322,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:00:01;03 59.94 Drop-Frame", - rate: Framerate.new!(59.94, :drop), + rate: Rates.f59_94_df(), seconds_inputs: [ Ratio.new(21_021, 20_000), 1.05105, @@ -327,7 +342,7 @@ defmodule Vtc.TimecodeTest do }, %TcParseCase{ name: "00:01:00;04 59.94 Drop-Frame", - rate: Framerate.new!(59.94, :drop), + rate: Rates.f59_94_df(), seconds_inputs: [ Ratio.new(3003, 50), 60.06, @@ -356,7 +371,7 @@ defmodule Vtc.TimecodeTest do @input_case input_case @negative_input ParseHelpers.make_negative_input(@input_case) - test "#{@test_case.name} | #{@input_case} | | #{@test_case.rate}" do + test "#{@test_case.name} | #{@input_case} | #{@test_case.rate}" do @input_case |> Timecode.with_seconds(@test_case.rate) |> check_parsed(@test_case) @@ -410,21 +425,25 @@ defmodule Vtc.TimecodeTest do end describe "#with_frames/2" do - for test_case <- @parse_cases do + @describetag with_frames: true + + for {test_case, case_index} <- Enum.with_index(@parse_cases) do @test_case test_case @test_case_negative ParseHelpers.make_negative_case(test_case) - for input_case <- @test_case.frames_inputs do + for {input_case, input_index} <- Enum.with_index(@test_case.frames_inputs) do @input_case input_case @negative_input ParseHelpers.make_negative_input(input_case) - test "#{@test_case.name} | #{@input_case} | #{@test_case.rate}" do + @tag case: :"with_frames_#{case_index}_#{input_index}" + test "#{@test_case.name} | #{case_index}:#{input_index} | #{@input_case} | #{@test_case.rate}" do @input_case |> Timecode.with_frames(@test_case.rate) |> check_parsed(@test_case) end - test "#{@test_case.name}! | #{@input_case} | #{@test_case.rate} | negative" do + @tag case: :"with_frames_#{case_index}_#{input_index}_negative" + test "#{@test_case.name}! | #{case_index}:#{input_index} | #{@input_case} | #{@test_case.rate} | negative" do @negative_input |> Timecode.with_frames(@test_case_negative.rate) |> check_parsed(@test_case_negative) @@ -645,39 +664,39 @@ defmodule Vtc.TimecodeTest do describe "#with_frames/2 - malformed tc" do @malformed_cases [ - %MalformedCase{ + %{ val_in: "00:59:59:24", expected: "01:00:00:00" }, - %MalformedCase{ + %{ val_in: "00:59:59:28", expected: "01:00:00:04" }, - %MalformedCase{ + %{ val_in: "00:00:62:04", expected: "00:01:02:04" }, - %MalformedCase{ + %{ val_in: "00:62:01:04", expected: "01:02:01:04" }, - %MalformedCase{ + %{ val_in: "00:62:62:04", expected: "01:03:02:04" }, - %MalformedCase{ + %{ val_in: "123:00:00:00", expected: "123:00:00:00" }, - %MalformedCase{ + %{ val_in: "01:00:00:48", expected: "01:00:02:00" }, - %MalformedCase{ + %{ val_in: "01:00:120:00", expected: "01:02:00:00" }, - %MalformedCase{ + %{ val_in: "01:120:00:00", expected: "03:00:00:00" } @@ -695,31 +714,31 @@ defmodule Vtc.TimecodeTest do describe "#with_frames/2 - partial tc" do @partial_tc_cases [ - %MalformedCase{ + %{ val_in: "1:02:03:04", expected: "01:02:03:04" }, - %MalformedCase{ + %{ val_in: "02:03:04", expected: "00:02:03:04" }, - %MalformedCase{ + %{ val_in: "2:03:04", expected: "00:02:03:04" }, - %MalformedCase{ + %{ val_in: "03:04", expected: "00:00:03:04" }, - %MalformedCase{ + %{ val_in: "3:04", expected: "00:00:03:04" }, - %MalformedCase{ + %{ val_in: "04", expected: "00:00:00:04" }, - %MalformedCase{ + %{ val_in: "4", expected: "00:00:00:04" } @@ -737,27 +756,27 @@ defmodule Vtc.TimecodeTest do describe "#with_seconds/2 - partial runtime" do @partial_runtime_cases [ - %MalformedCase{ + %{ val_in: "1:02:03.5", expected: "01:02:03.5" }, - %MalformedCase{ + %{ val_in: "02:03.5", expected: "00:02:03.5" }, - %MalformedCase{ + %{ val_in: "2:03.5", expected: "00:02:03.5" }, - %MalformedCase{ + %{ val_in: "03.5", expected: "00:00:03.5" }, - %MalformedCase{ + %{ val_in: "3.5", expected: "00:00:03.5" }, - %MalformedCase{ + %{ val_in: "0.5", expected: "00:00:00.5" } @@ -835,18 +854,18 @@ defmodule Vtc.TimecodeTest do for test_case <- @test_cases do @test_case test_case - test "#{@test_case[:a]} is #{@test_case[:expected]} #{@test_case[:b]}" do + test "#{@test_case.a} is #{@test_case.expected} #{@test_case.b}" do %{a: a, b: b, expected: expected} = @test_case assert Timecode.compare(a, b) == expected end - if @test_case[:a].rate == @test_case[:b].rate do - test "#{@test_case[:a]} is #{@test_case[:expected]} #{@test_case[:b]} | b = tc string" do + if @test_case.a.rate == @test_case.b.rate do + test "#{@test_case.a} is #{@test_case.expected} #{@test_case.b} | b = tc string" do %{a: a, b: b, expected: expected} = @test_case assert Timecode.compare(a, Timecode.timecode(b)) == expected end - test "#{@test_case[:a]} is #{@test_case[:expected]} #{@test_case[:b]} | b = frames int" do + test "#{@test_case.a} is #{@test_case.expected} #{@test_case.b} | b = frames int" do %{a: a, b: b, expected: expected} = @test_case assert Timecode.compare(a, Timecode.frames(b)) == expected end @@ -877,4 +896,99 @@ defmodule Vtc.TimecodeTest do end end end + + @rebase_cases [ + %{ + original: Timecode.with_frames!("01:00:00:00", Rates.f23_98()), + new_rate: Rates.f47_95(), + expected: Timecode.with_frames!("00:30:00:00", Rates.f47_95()) + }, + %{ + original: Timecode.with_frames!("01:00:00:00", Rates.f47_95()), + new_rate: Rates.f23_98(), + expected: Timecode.with_frames!("02:00:00:00", Rates.f23_98()) + }, + %{ + original: Timecode.with_frames!("01:00:00:00", Rates.f23_98()), + new_rate: Rates.f24(), + expected: Timecode.with_frames!("01:00:00:00", Rates.f24()) + }, + %{ + original: Timecode.with_frames!("01:00:00;00", Rates.f29_97_df()), + new_rate: Rates.f29_97_ndf(), + expected: Timecode.with_frames!("00:59:56;12", Rates.f29_97_ndf()) + }, + %{ + original: Timecode.with_frames!("01:00:00;00", Rates.f59_94_df()), + new_rate: Rates.f59_94_ndf(), + expected: Timecode.with_frames!("00:59:56;24", Rates.f59_94_ndf()) + } + ] + + describe "rebase/2" do + for rebase_case <- @rebase_cases do + @rebase_case rebase_case + + test "#{@rebase_case.original} -> #{@rebase_case.new_rate}" do + %{original: original, new_rate: new_rate, expected: expected} = @rebase_case + assert {:ok, rebased} = Timecode.rebase(original, new_rate) + assert rebased == expected + + assert {:ok, round_tripped} = Timecode.rebase(rebased, original.rate) + assert round_tripped == original + end + end + + property "round trip rebases do not lose accuracy" do + run_rebase_property_test(fn timecode, new_rate -> + assert {:ok, rebased} = Timecode.rebase(timecode, new_rate) + rebased + end) + end + end + + describe "rebase!/2" do + for rebase_case <- @rebase_cases do + @rebase_case rebase_case + + test "#{@rebase_case.original} -> #{@rebase_case.new_rate}" do + %{original: original, new_rate: new_rate, expected: expected} = @rebase_case + assert %Timecode{} = rebased = Timecode.rebase!(original, new_rate) + assert rebased == expected + + assert %Timecode{} = round_tripped = Timecode.rebase!(rebased, original.rate) + assert round_tripped == original + end + end + + property "round trip rebases do not lose accuracy" do + run_rebase_property_test(fn timecode, new_rate -> + Timecode.rebase!(timecode, new_rate) + end) + end + end + + @spec run_rebase_property_test((Timecode.t(), Framerate.t() -> Timecode.t())) :: term() + defp run_rebase_property_test(do_reabase) do + check all( + frames <- StreamData.integer(), + original_rate_x <- StreamData.integer(1..240), + original_ntsc <- StreamData.boolean(), + target_rate_x <- StreamData.integer(1..240), + target_ntsc <- StreamData.boolean(), + max_runs: 20 + ) do + original_ntsc = if original_ntsc, do: :non_drop, else: nil + origina_rate = Framerate.new!(original_rate_x, original_ntsc, false) + + target_ntsc = if target_ntsc, do: :non_drop, else: nil + target_rate = Framerate.new!(target_rate_x, target_ntsc, false) + + original = Timecode.with_frames!(frames, origina_rate) + + rebased = do_reabase.(original, target_rate) + round_trip = do_reabase.(rebased, origina_rate) + assert round_trip == original + end + end end