From 74ccf465228f2176b087dbc1126ada3346fd1aad Mon Sep 17 00:00:00 2001 From: lomavkin Date: Thu, 7 Sep 2023 19:47:59 +0900 Subject: [PATCH] add timecode --- LICENSE | 2 +- README.md | 30 +- go.mod | 11 + go.sum | 10 + timecode/example_test.go | 94 +++++ timecode/timecode.go | 277 +++++++++++++++ timecode/timecode_test.go | 703 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 1125 insertions(+), 2 deletions(-) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 timecode/example_test.go create mode 100644 timecode/timecode.go create mode 100644 timecode/timecode_test.go diff --git a/LICENSE b/LICENSE index a630aa5..35a100b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 ABEMA +Copyright (c) 2023 AbemaTV Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 03f44b3..0dbf1de 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ -# go-timecode \ No newline at end of file +go-timecode +=========== + +[![GoDoc](https://godoc.org/github.com/abema/go-timecode/timecode?status.svg)](https://godoc.org/github.com/abema/go-timecode/timecode) + +go-timecode is a Go library for SMPTE 12M timecodes. + +Features +----------- + +- supports drop-frame (DF) and non-drop-frame (NDF) +- supports standard film, video, and television editing rates of 10, 15, 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60 +- timecode and number of frames can be calculated +- convertable between timecode and number of frames + +Installation +----------- + +```shell +go get github.com/abema/go-timecode/timecode +``` + +Usage +----------- +[GoDoc Examples](https://godoc.org/github.com/abema/go-timecode/timecode/#pkg-examples). + +License +----------- +go-timecode is available under the [MIT License](https://opensource.org/license/mit/). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c96ce86 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/abema/go-timecode + +go 1.19 + +require github.com/stretchr/testify v1.8.4 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa4b6e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/timecode/example_test.go b/timecode/example_test.go new file mode 100644 index 0000000..3c97bb3 --- /dev/null +++ b/timecode/example_test.go @@ -0,0 +1,94 @@ +package timecode_test + +import ( + "fmt" + + "github.com/abema/go-timecode/timecode" +) + +func ExampleNewTimecode() { + tc, err := timecode.NewTimecode(1800, 30000, 1001) + if err != nil { + panic(1) + } + fmt.Println(tc) + // Output: 00:01:00:02 +} + +func ExampleParseTimecode() { + tc, err := timecode.ParseTimecode("00:09:00:00", 30000, 1001) + if err != nil { + panic(1) + } + fmt.Println(tc) + // Output: 00:09:00:02 +} + +func ExampleReset() { + tc, err := timecode.NewTimecode(1798, 30000, 1001) + if err != nil { + panic(1) + } + tcc, _ := timecode.Reset(tc, 1800) + fmt.Println(tcc) + // Output: 00:01:00:02 +} + +func ExampleTimecode_Add() { + tc1, err := timecode.NewTimecode(1798, 30000, 1001) + if err != nil { + panic(1) + } + tc2, err := timecode.NewTimecode(2, 30000, 1001) + if err != nil { + panic(1) + } + tc3, _ := tc1.Add(tc2) + fmt.Println(tc3) + // Output: + // 00:01:00:02 +} + +func ExampleTimecode_AddFrames() { + tc1, err := timecode.NewTimecode(1798, 30000, 1001) + if err != nil { + panic(1) + } + + tc2, _ := tc1.AddFrames(2) + fmt.Println(tc2) + // Output: 00:01:00:02 +} + +func ExampleTimecode_Sub() { + tc1, err := timecode.NewTimecode(1800, 30000, 1001) + if err != nil { + panic(1) + } + tc2, err := timecode.NewTimecode(2, 30000, 1001) + if err != nil { + panic(1) + } + tc3, _ := tc1.Sub(tc2) + fmt.Println(tc3) + // Output: 00:00:59:28 +} + +func ExampleTimecode_SubFrames() { + tc1, err := timecode.NewTimecode(1800, 30000, 1001) + if err != nil { + panic(1) + } + tc2, _ := tc1.SubFrames(2) + fmt.Println(tc2) + // Output: 00:00:59:28 +} + +func ExampleTimecode_String() { + tc, err := timecode.NewTimecode(3600, 60000, 1001) + if err != nil { + panic(1) + } + fmt.Println(tc.String()) + // Output: 00:01:00:04 +} diff --git a/timecode/timecode.go b/timecode/timecode.go new file mode 100644 index 0000000..3a23b39 --- /dev/null +++ b/timecode/timecode.go @@ -0,0 +1,277 @@ +package timecode + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "time" +) + +// rate represents frame rate. +type rate struct { + fps int + numerator int32 + denominator int32 + dropFrames int + framesPer1Min int + framesPer10Min int +} + +var ( + // supportedRates represents supported frame rates 23.976, 24, 25, 29.97DF, 30, 48, 50, 59.94DF, 60. + supportedRates = []*rate{ + {fps: 10, numerator: 10, denominator: 1, dropFrames: 0, framesPer1Min: 10 * 60, framesPer10Min: 10 * 600}, // 10 + {fps: 15, numerator: 15, denominator: 1, dropFrames: 0, framesPer1Min: 15 * 60, framesPer10Min: 15 * 600}, // 15 + {fps: 24, numerator: 24000, denominator: 1001, dropFrames: 0, framesPer1Min: 24 * 60, framesPer10Min: 24 * 600}, // 23.976 + {fps: 24, numerator: 24, denominator: 1, dropFrames: 0, framesPer1Min: 24 * 60, framesPer10Min: 24 * 600}, // 24 + {fps: 25, numerator: 25, denominator: 1, dropFrames: 0, framesPer1Min: 25 * 60, framesPer10Min: 25 * 600}, // 25 + {fps: 30, numerator: 30000, denominator: 1001, dropFrames: 2, framesPer1Min: 30*60 - 2, framesPer10Min: 30*600 - 9*2}, // 29.97DF + {fps: 30, numerator: 30, denominator: 1, dropFrames: 0, framesPer1Min: 30 * 60, framesPer10Min: 30 * 600}, // 30 + {fps: 48, numerator: 48, denominator: 1, dropFrames: 0, framesPer1Min: 48 * 60, framesPer10Min: 48 * 600}, // 48 + {fps: 50, numerator: 50, denominator: 1, dropFrames: 0, framesPer1Min: 50 * 60, framesPer10Min: 50 * 600}, // 50 + {fps: 60, numerator: 60000, denominator: 1001, dropFrames: 4, framesPer1Min: 60*60 - 4, framesPer10Min: 60*600 - 9*4}, // 59.94DF + {fps: 60, numerator: 60, denominator: 1, dropFrames: 0, framesPer1Min: 60 * 60, framesPer10Min: 60 * 600}, // 60 + } + + // timecodePattern represents timecode pattern. + timecodePattern = regexp.MustCompile(`^([01][0-9]|2[0-3])([p:;.,])([0-5][0-9])([p:;.,])([0-5][0-9])([:;.,])([0-5][0-9])$`) +) + +var ( + ErrNilTimecode = errors.New("nil timecode") // error for nil timecode + ErrUnsupportedFrameRate = errors.New("unsupported frame rate") // error for unsupported frame rate + ErrMismatchFrameRate = errors.New("mismatch frame rate") // error for mismatch frame rate + ErrUnderflowFrames = errors.New("underflow frames") // error for underflow frames + ErrInvalidTimecode = errors.New("invalid timecode") // error for invalid timecode + ErrTooManyFrames = errors.New("too many frames") // error for too many frames +) + +// Timecode represents timecode. +type Timecode struct { + optp TimecodeOptionParam + r *rate + HH uint64 + MM uint64 + SS uint64 + FF uint64 +} + +// TimecodeOptionParam represents timecode option parameter. +type TimecodeOptionParam struct { + Sep string + SepDF string +} + +// TimecodeOption represents timecode option. +type TimecodeOption func(*TimecodeOptionParam) + +// newTimecodeOptionParam returns new TimecodeOptionParam. +func newTimecodeOptionParam() TimecodeOptionParam { + return TimecodeOptionParam{ + Sep: ":", + SepDF: ":", + } +} + +// applyTimecodeOption applies TimecodeOption to TimecodeOptionParam. +func (p *TimecodeOptionParam) applyTimecodeOption(opts ...TimecodeOption) { + for _, opt := range opts { + opt(p) + } +} + +// newRate returns new rate. +func newRate(num, den int32) (*rate, error) { + fps := float64(num) / float64(den) + for _, r := range supportedRates { + if float64(r.numerator)/float64(r.denominator) == fps { + return r, nil + } + } + return nil, ErrUnsupportedFrameRate +} + +// IsSupportedFrameRate returns whether frame rate is supported. +func IsSupportedFrameRate(num, den int32) bool { + _, err := newRate(num, den) + return err == nil +} + +// IsRepresentableFrames returns whether frames is representable. +func IsRepresentableFrames(frames uint64, num, den int32) bool { + r, err := newRate(num, den) + if err != nil { + return false + } + return r.isRepresentableFrames(frames) +} + +// NewTimecode returns new Timecode. +func NewTimecode(frames uint64, num, den int32, opts ...TimecodeOption) (*Timecode, error) { + r, err := newRate(num, den) + if err != nil { + return nil, err + } + + p := newTimecodeOptionParam() + p.applyTimecodeOption(opts...) + + tc, err := Reset(&Timecode{r: r, optp: p}, frames) + if err != nil { + return nil, err + } + return tc, nil +} + +// ParseTimecode returns new Timecode from formatted string. +func ParseTimecode(s string, num, den int32) (*Timecode, error) { + r, err := newRate(num, den) + if err != nil { + return nil, err + } + + // pattern: HH Sep1 MM Sep2 SS Sep3 FF + // match : 1 2 3 4 5 6 7 + match := timecodePattern.FindStringSubmatch(s) + if len(match) != 8 || match[2] != match[4] { + return nil, ErrInvalidTimecode + } + + hh, _ := strconv.Atoi(match[1]) + sep := match[2] + mm, _ := strconv.Atoi(match[3]) + ss, _ := strconv.Atoi(match[5]) + sepDF := match[6] + ff, _ := strconv.Atoi(match[7]) + + if ff < r.dropFrames && mm%10 != 0 { + ff = r.dropFrames + } + + return &Timecode{ + r: r, + optp: TimecodeOptionParam{Sep: sep, SepDF: sepDF}, + HH: uint64(hh), + MM: uint64(mm), + SS: uint64(ss), + FF: uint64(ff), + }, nil +} + +// Reset returns new Timecode from Timecode and frames. +func Reset(tc *Timecode, frames uint64) (*Timecode, error) { + if tc == nil { + return nil, ErrNilTimecode + } + + new := *tc + + if !new.r.isRepresentableFrames(frames) { + return nil, ErrTooManyFrames + } + + d := frames / uint64(new.r.framesPer10Min) + m := frames % uint64(new.r.framesPer10Min) + df := uint64(new.r.dropFrames) + f := frames + 9*df*d + if m > df { + f += df * ((m - df) / uint64(new.r.framesPer1Min)) + } + + fps := uint64(new.r.fps) + new.FF = f % fps + new.SS = f / fps % 60 + new.MM = f / (fps * 60) % 60 + new.HH = f / (fps * 3600) + + return &new, nil +} + +// equal returns whether rate is equal. +func (r *rate) equal(other *rate) bool { + if r == nil || other == nil { + return false + } + return r.numerator == other.numerator && r.denominator == other.denominator +} + +// isRepresentableFrames returns whether frames is representable. +func (r *rate) isRepresentableFrames(frames uint64) bool { + return frames < uint64(24*6*r.framesPer10Min) +} + +// Frames returns number of frames. +func (tc *Timecode) Frames() uint64 { + var frames uint64 + frames += tc.HH * 3600 * uint64(tc.r.fps) + frames += tc.MM * 60 * uint64(tc.r.fps) + frames += tc.SS * uint64(tc.r.fps) + frames += tc.FF + + framesPer10Min := uint64(tc.r.fps) * 60 * 10 + framesPer1Min := framesPer10Min / 10 + + var df uint64 + df += (frames / framesPer10Min) * uint64(tc.r.dropFrames) * 9 + df += (frames % framesPer10Min) / framesPer1Min * uint64(tc.r.dropFrames) + + return frames - df +} + +// Duration returns duration from zero-origin. +func (tc *Timecode) Duration() time.Duration { + return time.Duration((float64(tc.Frames()) * float64(tc.r.denominator) / float64(tc.r.numerator)) * float64(time.Second)) +} + +// Add Timecode and Timecode and return new Timecode. +func (tc *Timecode) Add(other *Timecode) (*Timecode, error) { + if !tc.r.equal(other.r) { + return nil, ErrMismatchFrameRate + } + return Reset(tc, tc.Frames()+other.Frames()) +} + +// Sub Timecode and Timecode and return new Timecode. +func (tc *Timecode) Sub(other *Timecode) (*Timecode, error) { + if !tc.r.equal(other.r) { + return nil, ErrMismatchFrameRate + } + if tc.Frames() < other.Frames() { + return nil, ErrUnderflowFrames + } + return Reset(tc, tc.Frames()-other.Frames()) +} + +// Add Timecode and frames and return new Timecode. +func (tc *Timecode) AddFrames(frames uint64) (*Timecode, error) { + return Reset(tc, tc.Frames()+frames) +} + +// Sub Timecode and frames and return new Timecode. +func (tc *Timecode) SubFrames(frames uint64) (*Timecode, error) { + if tc.Frames() < frames { + return nil, ErrUnderflowFrames + } + return Reset(tc, tc.Frames()-frames) +} + +// String returns Timecode formatted string. +// e.g. 01:23:45:28 +func (tc *Timecode) String() string { + sep := tc.optp.Sep + lastSep := sep + if tc.r.dropFrames > 0 { + lastSep = tc.optp.SepDF + } + return fmt.Sprintf( + "%02d%s%02d%s%02d%s%02d", + tc.HH, + sep, + tc.MM, + sep, + tc.SS, + lastSep, + tc.FF, + ) +} diff --git a/timecode/timecode_test.go b/timecode/timecode_test.go new file mode 100644 index 0000000..79e1512 --- /dev/null +++ b/timecode/timecode_test.go @@ -0,0 +1,703 @@ +package timecode + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewRate(t *testing.T) { + t.Run("NaN", func(t *testing.T) { + _, err := newRate(1, 0) + assert.Error(t, err) + }) + t.Run("0fps", func(t *testing.T) { + _, err := newRate(0, 1001) + assert.Error(t, err) + }) + t.Run("1fps", func(t *testing.T) { + _, err := newRate(1, 1) + assert.Error(t, err) + }) + t.Run("23.976fps", func(t *testing.T) { + r, err := newRate(24000, 1001) + assert.NoError(t, err) + assert.Equal(t, 24, r.fps) + assert.Equal(t, 0, r.dropFrames) + assert.Equal(t, 24*60, r.framesPer1Min) + assert.Equal(t, 24*600, r.framesPer10Min) + }) + t.Run("24fps", func(t *testing.T) { + r, err := newRate(24, 1) + assert.NoError(t, err) + assert.Equal(t, 24, r.fps) + assert.Equal(t, 0, r.dropFrames) + assert.Equal(t, 24*60, r.framesPer1Min) + assert.Equal(t, 24*600, r.framesPer10Min) + }) + t.Run("25fps", func(t *testing.T) { + r, err := newRate(25, 1) + assert.NoError(t, err) + assert.Equal(t, 25, r.fps) + assert.Equal(t, 0, r.dropFrames) + assert.Equal(t, 25*60, r.framesPer1Min) + assert.Equal(t, 25*600, r.framesPer10Min) + }) + t.Run("29.97fps", func(t *testing.T) { + r, err := newRate(30000, 1001) + assert.NoError(t, err) + assert.Equal(t, 30, r.fps) + assert.Equal(t, 2, r.dropFrames) + assert.Equal(t, 30*60-2, r.framesPer1Min) + assert.Equal(t, 30*600-9*2, r.framesPer10Min) + }) + t.Run("30fps", func(t *testing.T) { + r, err := newRate(30, 1) + assert.NoError(t, err) + assert.Equal(t, 30, r.fps) + assert.Equal(t, 0, r.dropFrames) + assert.Equal(t, 30*60, r.framesPer1Min) + assert.Equal(t, 30*600, r.framesPer10Min) + }) + t.Run("48fps", func(t *testing.T) { + r, err := newRate(48, 1) + assert.NoError(t, err) + assert.Equal(t, 48, r.fps) + assert.Equal(t, 0, r.dropFrames) + assert.Equal(t, 48*60, r.framesPer1Min) + assert.Equal(t, 48*600, r.framesPer10Min) + }) + t.Run("50fps", func(t *testing.T) { + r, err := newRate(50, 1) + assert.NoError(t, err) + assert.Equal(t, 50, r.fps) + assert.Equal(t, 0, r.dropFrames) + assert.Equal(t, 50*60, r.framesPer1Min) + assert.Equal(t, 50*600, r.framesPer10Min) + }) + t.Run("59.94fps", func(t *testing.T) { + r, err := newRate(60000, 1001) + assert.NoError(t, err) + assert.Equal(t, 60, r.fps) + assert.Equal(t, 4, r.dropFrames) + assert.Equal(t, 60*60-4, r.framesPer1Min) + assert.Equal(t, 60*600-9*4, r.framesPer10Min) + }) + t.Run("60fps", func(t *testing.T) { + r, err := newRate(60, 1) + assert.NoError(t, err) + assert.Equal(t, 60, r.fps) + assert.Equal(t, 0, r.dropFrames) + assert.Equal(t, 60*60, r.framesPer1Min) + assert.Equal(t, 60*600, r.framesPer10Min) + }) + t.Run("error/23.995fps", func(t *testing.T) { + r, err := newRate(29995, 1000) + assert.Equal(t, ErrUnsupportedFrameRate, err) + assert.Nil(t, r) + }) + t.Run("error/23.997fps", func(t *testing.T) { + r, err := newRate(29997, 1000) + assert.Equal(t, ErrUnsupportedFrameRate, err) + assert.Nil(t, r) + }) + t.Run("error/29.96fps", func(t *testing.T) { + r, err := newRate(29960, 1000) + assert.Equal(t, ErrUnsupportedFrameRate, err) + assert.Nil(t, r) + }) + t.Run("error/29.98fps", func(t *testing.T) { + r, err := newRate(29980, 1000) + assert.Equal(t, ErrUnsupportedFrameRate, err) + assert.Nil(t, r) + }) + t.Run("error/59.93fps", func(t *testing.T) { + r, err := newRate(59930, 1000) + assert.Equal(t, ErrUnsupportedFrameRate, err) + assert.Nil(t, r) + }) + t.Run("error/59.95fps", func(t *testing.T) { + r, err := newRate(59950, 1000) + assert.Equal(t, ErrUnsupportedFrameRate, err) + assert.Nil(t, r) + }) + t.Run("error/60.001fps", func(t *testing.T) { + r, err := newRate(60001, 1000) + assert.Equal(t, ErrUnsupportedFrameRate, err) + assert.Nil(t, r) + }) +} + +func TestNewTestcodeNonDF(t *testing.T) { + t.Run("NaN", func(t *testing.T) { + _, err := NewTimecode(1, 1, 0) + assert.Error(t, err) + }) + t.Run("0fps", func(t *testing.T) { + _, err := NewTimecode(1, 0, 1001) + assert.Error(t, err) + }) + t.Run("1fps", func(t *testing.T) { + _, err := NewTimecode(1, 1, 1) + assert.Error(t, err) + }) + t.Run("23.976fps", func(t *testing.T) { + tc, err := NewTimecode(1439, 24000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:23", tc.String()) + assert.Equal(t, uint64(1439), tc.Frames()) + assert.Equal(t, 60.018, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(1440, 24000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:00", tc.String()) + assert.Equal(t, uint64(1440), tc.Frames()) + + tc, err = NewTimecode(1441, 24000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:01", tc.String()) + assert.Equal(t, uint64(1441), tc.Frames()) + + tc, err = NewTimecode(1440*10, 24000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(1440*10), tc.Frames()) + assert.Equal(t, 600.6, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(1440*10+1, 24000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:01", tc.String()) + assert.Equal(t, uint64(1440*10+1), tc.Frames()) + + maxFrames := uint64(24*6*(1440*10)) - 1 + tc, err = NewTimecode(maxFrames, 24000, 1001) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:23", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + + tc, err = NewTimecode(maxFrames+1, 24000, 1001) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) + t.Run("24fps", func(t *testing.T) { + tc, err := NewTimecode(1439, 24, 1) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:23", tc.String()) + assert.Equal(t, uint64(1439), tc.Frames()) + assert.Equal(t, 59.958, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(1440, 24, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:00", tc.String()) + assert.Equal(t, uint64(1440), tc.Frames()) + + tc, err = NewTimecode(1441, 24, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:01", tc.String()) + assert.Equal(t, uint64(1441), tc.Frames()) + + tc, err = NewTimecode(1440*10, 24, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(1440*10), tc.Frames()) + assert.Equal(t, 600.0, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(1440*10+1, 24, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:01", tc.String()) + assert.Equal(t, uint64(1440*10+1), tc.Frames()) + + maxFrames := uint64(24*6*(1440*10)) - 1 + tc, err = NewTimecode(maxFrames, 24, 1) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:23", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + assert.Equal(t, 86399.958, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(maxFrames+1, 24, 1) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) + t.Run("25", func(t *testing.T) { + tc, err := NewTimecode(1499, 25, 1) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:24", tc.String()) + assert.Equal(t, uint64(1499), tc.Frames()) + assert.Equal(t, 59.96, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(1500, 25, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:00", tc.String()) + assert.Equal(t, uint64(1500), tc.Frames()) + + tc, err = NewTimecode(1501, 25, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:01", tc.String()) + assert.Equal(t, uint64(1501), tc.Frames()) + + tc, err = NewTimecode(1500*10, 25, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(1500*10), tc.Frames()) + assert.Equal(t, 600.0, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(1500*10+1, 25, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:01", tc.String()) + assert.Equal(t, uint64(1500*10+1), tc.Frames()) + + maxFrames := uint64(24*6*(1500*10)) - 1 + tc, err = NewTimecode(maxFrames, 25, 1) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:24", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + assert.Equal(t, 86399.96, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(maxFrames+1, 25, 1) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) + t.Run("30fps", func(t *testing.T) { + tc, err := NewTimecode(1799, 30, 1) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:29", tc.String()) + assert.Equal(t, uint64(1799), tc.Frames()) + assert.Equal(t, 59.967, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(1800, 30, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:00", tc.String()) + assert.Equal(t, uint64(1800), tc.Frames()) + + tc, err = NewTimecode(1801, 30, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:01", tc.String()) + assert.Equal(t, uint64(1801), tc.Frames()) + + tc, err = NewTimecode(1800*10, 30, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(1800*10), tc.Frames()) + assert.Equal(t, 600.0, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(1800*10+1, 30, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:01", tc.String()) + assert.Equal(t, uint64(1800*10+1), tc.Frames()) + + maxFrames := uint64(24*6*(1800*10)) - 1 + tc, err = NewTimecode(maxFrames, 30, 1) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:29", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + assert.Equal(t, 86399.967, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(maxFrames+1, 30, 1) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) + t.Run("48", func(t *testing.T) { + tc, err := NewTimecode(2879, 48, 1) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:47", tc.String()) + assert.Equal(t, uint64(2879), tc.Frames()) + assert.Equal(t, 59.979, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(2880, 48, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:00", tc.String()) + assert.Equal(t, uint64(2880), tc.Frames()) + + tc, err = NewTimecode(2881, 48, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:01", tc.String()) + assert.Equal(t, uint64(2881), tc.Frames()) + + tc, err = NewTimecode(2880*10, 48, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(2880*10), tc.Frames()) + assert.Equal(t, 600.0, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(2880*10+1, 48, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:01", tc.String()) + assert.Equal(t, uint64(2880*10+1), tc.Frames()) + + maxFrames := uint64(24*6*(2880*10)) - 1 + tc, err = NewTimecode(maxFrames, 48, 1) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:47", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + assert.Equal(t, 86399.979, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(maxFrames+1, 48, 1) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) + t.Run("50fps", func(t *testing.T) { + tc, err := NewTimecode(2999, 50, 1) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:49", tc.String()) + assert.Equal(t, uint64(2999), tc.Frames()) + assert.Equal(t, 59.98, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(3000, 50, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:00", tc.String()) + assert.Equal(t, uint64(3000), tc.Frames()) + + tc, err = NewTimecode(3001, 50, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:01", tc.String()) + assert.Equal(t, uint64(3001), tc.Frames()) + + tc, err = NewTimecode(3000*10, 50, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(3000*10), tc.Frames()) + assert.Equal(t, 600.0, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(3000*10+1, 50, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:01", tc.String()) + assert.Equal(t, uint64(3000*10+1), tc.Frames()) + + maxFrames := uint64(24*6*(3000*10)) - 1 + tc, err = NewTimecode(maxFrames, 50, 1) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:49", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + assert.Equal(t, 86399.98, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(maxFrames+1, 50, 1) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) + t.Run("60fps", func(t *testing.T) { + tc, err := NewTimecode(0, 60, 1) + assert.NoError(t, err) + assert.Equal(t, "00:00:00:00", tc.String()) + assert.Equal(t, uint64(0), tc.Frames()) + assert.Equal(t, 0.0, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(3599, 60, 1) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:59", tc.String()) + assert.Equal(t, uint64(3599), tc.Frames()) + + tc, err = NewTimecode(3600, 60, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:00", tc.String()) + assert.Equal(t, uint64(3600), tc.Frames()) + + tc, err = NewTimecode(3601, 60, 1) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:01", tc.String()) + assert.Equal(t, uint64(3601), tc.Frames()) + + tc, err = NewTimecode(3600*10, 60, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(3600*10), tc.Frames()) + assert.Equal(t, 600.0, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(3600*10+1, 60, 1) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:01", tc.String()) + assert.Equal(t, uint64(3600*10+1), tc.Frames()) + + maxFrames := uint64(24*6*(3600*10)) - 1 + tc, err = NewTimecode(maxFrames, 60, 1) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:59", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + assert.Equal(t, 86399.983, math.Round(tc.Duration().Seconds()*1000)/1000) + + tc, err = NewTimecode(maxFrames+1, 60, 1) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) +} + +func TestNewTestcodeDF(t *testing.T) { + t.Run("30DF", func(t *testing.T) { + tc, err := NewTimecode(1798, 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:28", tc.String()) + assert.Equal(t, uint64(1798), tc.Frames()) + + tc, err = NewTimecode(1799, 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:29", tc.String()) + assert.Equal(t, uint64(1799), tc.Frames()) + + tc, err = NewTimecode(1800, 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:02", tc.String()) + assert.Equal(t, uint64(1800), tc.Frames()) + + tc, err = NewTimecode(1800+1798*8, 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:09:00:02", tc.String()) + assert.Equal(t, uint64(1800+1798*8), tc.Frames()) + + tc, err = NewTimecode(1800+1798*9, 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(1800+1798*9), tc.Frames()) + + tc, err = NewTimecode(1800+1798*9+1799, 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:10:59:29", tc.String()) + assert.Equal(t, uint64(1800+1798*9+1799), tc.Frames()) + + tc, err = NewTimecode(1800+1798*9+1800, 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:11:00:02", tc.String()) + assert.Equal(t, uint64(1800+1798*9+1800), tc.Frames()) + + maxFrames := uint64(24*6*(1800+1798*9)) - 1 + tc, err = NewTimecode(maxFrames, 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:29", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + + tc, err = NewTimecode(maxFrames+1, 30000, 1001) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) + t.Run("60DF", func(t *testing.T) { + tc, err := NewTimecode(3596, 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:56", tc.String()) + assert.Equal(t, uint64(3596), tc.Frames()) + + tc, err = NewTimecode(3599, 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:00:59:59", tc.String()) + assert.Equal(t, uint64(3599), tc.Frames()) + + tc, err = NewTimecode(3600, 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:01:00:04", tc.String()) + assert.Equal(t, uint64(3600), tc.Frames()) + + tc, err = NewTimecode(3600+3596*8, 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:09:00:04", tc.String()) + assert.Equal(t, uint64(3600+3596*8), tc.Frames()) + + tc, err = NewTimecode(3600+3596*9, 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:10:00:00", tc.String()) + assert.Equal(t, uint64(3600+3596*9), tc.Frames()) + + tc, err = NewTimecode(3600+3596*9+3599, 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:10:59:59", tc.String()) + assert.Equal(t, uint64(3600+3596*9+3599), tc.Frames()) + + tc, err = NewTimecode(3600+3596*9+3600, 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:11:00:04", tc.String()) + assert.Equal(t, uint64(3600+3596*9+3600), tc.Frames()) + + maxFrames := uint64(24*6*(3600+3596*9)) - 1 + tc, err = NewTimecode(maxFrames, 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "23:59:59:59", tc.String()) + assert.Equal(t, maxFrames, tc.Frames()) + + tc, err = NewTimecode(maxFrames+1, 60000, 1001) + assert.Equal(t, ErrTooManyFrames, err) + assert.Nil(t, tc) + }) +} + +func TestParseTimecode(t *testing.T) { + t.Run("ParseTimecode", func(t *testing.T) { + tc, err := ParseTimecode("00:01:00;00", 30000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:01:00;02", tc.String()) + assert.Equal(t, uint64(1800), tc.Frames()) + assert.Equal(t, ":", tc.optp.Sep) + assert.Equal(t, ";", tc.optp.SepDF) + }) + t.Run("ParseTimecode/19h", func(t *testing.T) { + tc, err := ParseTimecode("19:00:00;00", 24, 1) + assert.NoError(t, err) + assert.Equal(t, uint64(1641600), tc.Frames()) + assert.Equal(t, ":", tc.optp.Sep) + assert.Equal(t, ";", tc.optp.SepDF) + }) + t.Run("ParseTimecode/23h", func(t *testing.T) { + tc, err := ParseTimecode("23:00:00;00", 24, 1) + assert.NoError(t, err) + assert.Equal(t, uint64(1987200), tc.Frames()) + assert.Equal(t, ":", tc.optp.Sep) + assert.Equal(t, ";", tc.optp.SepDF) + }) + t.Run("ParseTimecode/24h", func(t *testing.T) { + tc, err := ParseTimecode("24:01:00;00", 24, 1) + assert.Nil(t, tc) + assert.Equal(t, ErrInvalidTimecode, err) + }) + t.Run("ParseTimecode/skip timecode", func(t *testing.T) { + tc, err := ParseTimecode("00:09:00:03", 60000, 1001) + assert.NoError(t, err) + assert.Equal(t, "00:09:00:04", tc.String()) + }) + t.Run("ParseTimecode/overflow", func(t *testing.T) { + tc, err := ParseTimecode("00:09:00:99", 60000, 1001) + assert.Error(t, err) + assert.Nil(t, tc) + }) + t.Run("ParseTimecode/lacks character", func(t *testing.T) { + tc, err := ParseTimecode("0:01:00:00", 24, 1) + assert.Nil(t, tc) + assert.Equal(t, ErrInvalidTimecode, err) + }) + t.Run("ParseTimecode/superfluous character", func(t *testing.T) { + tc, err := ParseTimecode("0:01:000:00", 24, 1) + assert.Nil(t, tc) + assert.Equal(t, ErrInvalidTimecode, err) + }) + t.Run("ParseTimecode/invalid character", func(t *testing.T) { + tc, err := ParseTimecode("00:01:00:0x", 24, 1) + assert.Nil(t, tc) + assert.Equal(t, ErrInvalidTimecode, err) + }) + t.Run("ParseTimecode/invalid separator", func(t *testing.T) { + tc, err := ParseTimecode("00:01?00:0x", 24, 1) + assert.Nil(t, tc) + assert.Equal(t, ErrInvalidTimecode, err) + }) + t.Run("ParseTimecode/separators are inconsistent", func(t *testing.T) { + tc, err := ParseTimecode("00;01:00;00", 24, 1) + assert.Nil(t, tc) + assert.Equal(t, ErrInvalidTimecode, err) + }) +} + +func TestReset(t *testing.T) { + t.Run("Reset", func(t *testing.T) { + const ( + maxFrames = uint64(24*6*(3600+3596*9)) - 1 + tooManyFrames = maxFrames + 1 + ) + + tc1, _ := NewTimecode(3596, 60000, 1001) + tc2, _ := Reset(tc1, maxFrames) + assert.Equal(t, "00:00:59:56", tc1.String()) + assert.Equal(t, "23:59:59:59", tc2.String()) + + tcErr, err := Reset(tc2, tooManyFrames) + assert.Equal(t, "23:59:59:59", tc2.String()) + assert.Nil(t, tcErr) + assert.Equal(t, ErrTooManyFrames, err) + }) +} + +func TestAdd(t *testing.T) { + t.Run("Add timecode", func(t *testing.T) { + tc1, _ := NewTimecode(1798, 30000, 1001) + tc2, _ := NewTimecode(2, 30000, 1001) + tc3, _ := tc1.Add(tc2) + assert.Equal(t, "00:01:00:02", tc3.String()) + }) + t.Run("Add frames", func(t *testing.T) { + tc1, _ := NewTimecode(17000, 30000, 1001) + tc2, _ := tc1.AddFrames(982) + assert.Equal(t, "00:10:00:00", tc2.String()) + }) + t.Run("Add/mismatch frame rate", func(t *testing.T) { + tc1, _ := NewTimecode(1, 30, 1) + tc2, _ := NewTimecode(1, 30000, 1001) + tc3, err := tc1.Add(tc2) + assert.Nil(t, tc3) + assert.Equal(t, ErrMismatchFrameRate, err) + }) + t.Run("Add/overflow", func(t *testing.T) { + tc1, _ := NewTimecode(2589407, 30000, 1001) + tc2, _ := NewTimecode(1, 30000, 1001) + tc3, err := tc1.Add(tc2) + assert.Nil(t, tc3) + assert.Equal(t, ErrTooManyFrames, err) + }) + t.Run("Add frames/overflow", func(t *testing.T) { + tc1, _ := NewTimecode(2589407, 30000, 1001) + tc2, err := tc1.AddFrames(1) + assert.Nil(t, tc2) + assert.Equal(t, ErrTooManyFrames, err) + }) +} + +func TestSub(t *testing.T) { + t.Run("Sub timecode", func(t *testing.T) { + tc1, _ := NewTimecode(1800, 30000, 1001) + tc2, _ := NewTimecode(1, 30000, 1001) + tc3, _ := tc1.Sub(tc2) + assert.Equal(t, "00:00:59:29", tc3.String()) + }) + t.Run("Sub frames", func(t *testing.T) { + tc1, _ := NewTimecode(17982, 30000, 1001) + tc2, _ := tc1.SubFrames(1798) + assert.Equal(t, "00:09:00:02", tc2.String()) + }) + t.Run("Sub/mismatch frame rate", func(t *testing.T) { + tc1, _ := NewTimecode(1, 24, 1) + tc2, _ := NewTimecode(1, 24000, 1001) + tc3, err := tc1.Sub(tc2) + assert.Nil(t, tc3) + assert.Equal(t, ErrMismatchFrameRate, err) + }) + t.Run("Sub/underflow", func(t *testing.T) { + tc1, _ := NewTimecode(1, 30000, 1001) + tc2, _ := NewTimecode(10, 30000, 1001) + tc3, err := tc1.Sub(tc2) + assert.Nil(t, tc3) + assert.Equal(t, ErrUnderflowFrames, err) + }) + t.Run("Sub frames/underflow", func(t *testing.T) { + tc1, _ := NewTimecode(10, 30000, 1001) + tc2, err := tc1.SubFrames(11) + assert.Nil(t, tc2) + assert.Equal(t, ErrUnderflowFrames, err) + }) +} + +func TestTimecodeOption(t *testing.T) { + t.Run("single option/DF", func(t *testing.T) { + opt := func(p *TimecodeOptionParam) { + p.Sep = "." + p.SepDF = "," + } + tc, err := NewTimecode(3596, 60000, 1001, opt) + assert.NoError(t, err) + assert.Equal(t, "00.00.59,56", tc.String()) + }) + t.Run("multiple options/DF", func(t *testing.T) { + opt1 := func(p *TimecodeOptionParam) { + p.Sep = "," + } + opt2 := func(p *TimecodeOptionParam) { + p.SepDF = ";" + } + tc, err := NewTimecode(3596, 60000, 1001, opt1, opt2) + assert.NoError(t, err) + assert.Equal(t, "00,00,59;56", tc.String()) + }) + t.Run("multiple options/NDF", func(t *testing.T) { + opt1 := func(p *TimecodeOptionParam) { + p.Sep = "." + } + opt2 := func(p *TimecodeOptionParam) { + p.SepDF = ";" + } + tc, err := NewTimecode(3596, 60, 1, opt1, opt2) + assert.NoError(t, err) + assert.Equal(t, "00.00.59.56", tc.String()) + }) +}