From 7f60f01793a0b575507f97fe78cadbe55fa90aaa Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Thu, 17 Sep 2020 02:57:58 -0700 Subject: [PATCH] Initial Commit --- .gitignore | 72 ++++ LICENSE | 21 + Package.swift | 38 ++ README.md | 64 +++ .../SwiftTimecode/Components/Component.swift | 25 ++ .../SwiftTimecode/Components/Components.swift | 121 ++++++ .../OTTimecode Components.swift | 86 +++++ .../OTTimecode Real Time.swift | 87 +++++ .../Data Interchange/OTTimecode Samples.swift | 137 +++++++ .../Data Interchange/OTTimecode String.swift | 337 ++++++++++++++++ .../Formatter/TextFormatter.swift | 364 ++++++++++++++++++ .../FrameRate/FrameRate Properties.swift | 303 +++++++++++++++ .../FrameRate String Extensions.swift | 20 + .../SwiftTimecode/FrameRate/FrameRate.swift | 135 +++++++ .../Math/OTTimecode Math Internal.swift | 270 +++++++++++++ .../Math/OTTimecode Math Public.swift | 270 +++++++++++++ .../Math/OTTimecode Operators.swift | 54 +++ Sources/SwiftTimecode/OTTime/OTTime.swift | 85 ++++ .../OTTimecode Elapsed Frames.swift | 188 +++++++++ .../OTTimecode String Extensions.swift | 63 +++ .../SwiftTimecode/OTTimecode Validation.swift | 235 +++++++++++ Sources/SwiftTimecode/OTTimecode init.swift | 208 ++++++++++ Sources/SwiftTimecode/OTTimecode.swift | 90 +++++ .../Protocol Adoptions/Comparable.swift | 25 ++ .../CustomStringConvertible.swift | 35 ++ .../Protocol Adoptions/Hashable.swift | 27 ++ .../Protocol Adoptions/Strideable.swift | 32 ++ Sources/SwiftTimecode/UI/TextField.swift | 65 ++++ .../SwiftTimecode/UpperLimit/UpperLimit.swift | 64 +++ .../Utilities/BinaryInteger.swift | 30 ++ .../Utilities/CharacterSet.swift | 22 ++ Sources/SwiftTimecode/Utilities/Clamped.swift | 70 ++++ Sources/SwiftTimecode/Utilities/File.swift | 94 +++++ .../Utilities/FloatingPoint.swift | 151 ++++++++ Sources/SwiftTimecode/Utilities/RegEx.swift | 75 ++++ Sources/SwiftTimecode/Utilities/Text.swift | 49 +++ Tests/LinuxMain.swift | 4 + ...imecode Elapsed Frames ExtendedTests.swift | 110 ++++++ .../OTTimecode Integration Tests.swift | 124 ++++++ .../Components/Components Tests.swift | 24 ++ .../OTTimecode Components Tests.swift | 142 +++++++ .../OTTimecode Real Time Tests.swift | 151 ++++++++ .../OTTimecode Samples Tests.swift | 164 ++++++++ .../OTTimecode String Tests.swift | 335 ++++++++++++++++ .../Math/OTTimecode Math Public Tests.swift | 228 +++++++++++ .../Math/OTTimecode Operators Tests.swift | 69 ++++ .../Unit Tests/OTTime/OTTime Tests.swift | 65 ++++ .../OTTimecode Elapsed Frames.swift | 95 +++++ .../OTTimecode String Extensions Tests.swift | 25 ++ .../Unit Tests/OTTimecode Tests.swift | 31 ++ .../OTTimecode Validation Tests.swift | 150 ++++++++ .../Unit Tests/OTTimecode init Tests.swift | 95 +++++ .../Protocol Adoptions/Comparable Tests.swift | 37 ++ .../Protocol Adoptions/Hashable Tests.swift | 67 ++++ .../Protocol Adoptions/Strideable Tests.swift | 137 +++++++ 55 files changed, 6065 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/SwiftTimecode/Components/Component.swift create mode 100644 Sources/SwiftTimecode/Components/Components.swift create mode 100644 Sources/SwiftTimecode/Data Interchange/OTTimecode Components.swift create mode 100644 Sources/SwiftTimecode/Data Interchange/OTTimecode Real Time.swift create mode 100644 Sources/SwiftTimecode/Data Interchange/OTTimecode Samples.swift create mode 100644 Sources/SwiftTimecode/Data Interchange/OTTimecode String.swift create mode 100644 Sources/SwiftTimecode/Formatter/TextFormatter.swift create mode 100644 Sources/SwiftTimecode/FrameRate/FrameRate Properties.swift create mode 100644 Sources/SwiftTimecode/FrameRate/FrameRate String Extensions.swift create mode 100644 Sources/SwiftTimecode/FrameRate/FrameRate.swift create mode 100644 Sources/SwiftTimecode/Math/OTTimecode Math Internal.swift create mode 100644 Sources/SwiftTimecode/Math/OTTimecode Math Public.swift create mode 100644 Sources/SwiftTimecode/Math/OTTimecode Operators.swift create mode 100644 Sources/SwiftTimecode/OTTime/OTTime.swift create mode 100644 Sources/SwiftTimecode/OTTimecode Elapsed Frames.swift create mode 100644 Sources/SwiftTimecode/OTTimecode String Extensions.swift create mode 100644 Sources/SwiftTimecode/OTTimecode Validation.swift create mode 100644 Sources/SwiftTimecode/OTTimecode init.swift create mode 100644 Sources/SwiftTimecode/OTTimecode.swift create mode 100644 Sources/SwiftTimecode/Protocol Adoptions/Comparable.swift create mode 100644 Sources/SwiftTimecode/Protocol Adoptions/CustomStringConvertible.swift create mode 100644 Sources/SwiftTimecode/Protocol Adoptions/Hashable.swift create mode 100644 Sources/SwiftTimecode/Protocol Adoptions/Strideable.swift create mode 100644 Sources/SwiftTimecode/UI/TextField.swift create mode 100644 Sources/SwiftTimecode/UpperLimit/UpperLimit.swift create mode 100644 Sources/SwiftTimecode/Utilities/BinaryInteger.swift create mode 100644 Sources/SwiftTimecode/Utilities/CharacterSet.swift create mode 100644 Sources/SwiftTimecode/Utilities/Clamped.swift create mode 100644 Sources/SwiftTimecode/Utilities/File.swift create mode 100644 Sources/SwiftTimecode/Utilities/FloatingPoint.swift create mode 100644 Sources/SwiftTimecode/Utilities/RegEx.swift create mode 100644 Sources/SwiftTimecode/Utilities/Text.swift create mode 100644 Tests/LinuxMain.swift create mode 100644 Tests/SwiftTimecode-Dev-Tests/OTTimecode Elapsed Frames ExtendedTests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Integration Tests/OTTimecode Integration Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Components/Components Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Components Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Real Time Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Samples Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode String Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Math/OTTimecode Math Public Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Math/OTTimecode Operators Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTime/OTTime Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Elapsed Frames.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode String Extensions Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Validation Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode init Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Comparable Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Hashable Tests.swift create mode 100644 Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Strideable Tests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a3ef46eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Xcode + +# macOS +.DS_Store + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +## SPM support in Xcode +.swiftpm + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +Packages/ +Package.pins +Package.resolved +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control + +Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..42ebacb4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Steffan Andrews - https://github.com/orchetect + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..f6929ce8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + + name: "SwiftTimecode", + + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "SwiftTimecode", + targets: ["SwiftTimecode"]) + ], + + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + .target( + name: "SwiftTimecode", + dependencies: []), + .testTarget( + name: "SwiftTimecode-Unit-Tests", + dependencies: ["SwiftTimecode"]), + + .testTarget( + name: "SwiftTimecode-Dev-Tests", + dependencies: ["SwiftTimecode"] + ) + ] + +) diff --git a/README.md b/README.md new file mode 100644 index 00000000..36e46a27 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# SwiftTimecode + +

+Swift 5.3 compatible +Swift Package Manager (SPM) compatible +Platform - macOS | iOS | tvOS | watchOS +Linux - not tested +License: MIT +

+ +A robust library for working with SMPTE timecode supporting 20 industry frame rates, and including methods to convert to/from timecode strings and perform calculations. + +## Supported Frame Rates + +| NTSC | PAL | HD / Film | Other | +| --------- | ---- | --------- | ------ | +| 29.97 | 25 | 23.976 | 30 | +| 29.97 DF | 50 | 24 | 30 DF | +| 59.94 | 100 | 24.98 | 60 | +| 59.94 DF | | 47.952 | 60 DF | +| 119.88 | | 48 | 120 | +| 119.88 DF | | | 120 DF | + +## Core Features + +- Convert timecode to string, or a timecode string to values +- Convert timecode to real wall-clock time, and vice-versa +- Convert timecode to # of samples at any audio sample-rate, and vice-versa +- Granular timecode validation +- A Formatter object that can format timecode and also provide an NSAttributedString showing invalid timecode components in an alternate color (such as red) +- Support for Days as a timecode component (which Cubase supports as part of its timecode format) +- Support for sub-frames +- Common math operations between timecodes: add, subtract, multiply, divide +- Exhaustive unit tests ensuring accuracy + +## Development Status + +### Incomplete Features (Still in Development) + +- [ ] Complete sub-frame support + + - [ ] Test subFrameDivisor effect when set to -1, 0, 1, or 1000000 + - [ ] Needs to be added to String getters/setters + - Add 100-120 fps 3-digit frames display support + +- [ ] Add method to convert to another framerate by producing a new `OTTimecode` object. But make it clear that it's a LOSSY process. Suggested API: + + ```swift + func convert(to: FrameRate, limit: UpperLimit) -> OTTimecode + ``` + +### Maintenance + +- [ ] Add code examples to README.md or wiki. + +### Future Features Planned + +- None at this time. + +## Known Issues + +- The Dev Tests are not meant to be run as routine unit tests, but are designed as a test harness to be used only when altering critical parts of the library to ensure stability of internal calculations. +- Unit Tests won't build/run for watchOS Simulator because XCTest does not work on watchOS + - Workaround: Don't run unit tests for a watchOS target. Using macOS or iOS as a unit test target should be sufficient enough. If anyone runs into issues with the library on watchOS, feel free to contribute a solution or fix anything that requires fixing. diff --git a/Sources/SwiftTimecode/Components/Component.swift b/Sources/SwiftTimecode/Components/Component.swift new file mode 100644 index 00000000..0b531e16 --- /dev/null +++ b/Sources/SwiftTimecode/Components/Component.swift @@ -0,0 +1,25 @@ +// +// Component.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + /// Enum naming an individual timecode component + public enum Component { + + case days + case hours + case minutes + case seconds + case frames + case subFrames + + } + +} diff --git a/Sources/SwiftTimecode/Components/Components.swift b/Sources/SwiftTimecode/Components/Components.swift new file mode 100644 index 00000000..2200d593 --- /dev/null +++ b/Sources/SwiftTimecode/Components/Components.swift @@ -0,0 +1,121 @@ +// +// Components.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2018-07-20. +// Copyright © 2018 Steffan Andrews. All rights reserved. +// + +import Foundation + +/// Convenience typealias for cleaner code. +public typealias TCC = OTTimecode.Components + +extension OTTimecode { + + /// Primitive struct that can contain timecode values, agnostic of frame rate. + /// Raw values are stored and are not internally limited or clamped. + /// The global typealias TCC() is also available for convenience. + public struct Components { + + // MARK: Contents + + /// Days + public var d: Int + + /// Hours + public var h: Int + + /// Minutes + public var m: Int + + /// Seconds + public var s: Int + + /// Frames + public var f: Int + + /// Subframe component (expressed as unit interval 0.0...1.0) + public var sf: Int + + // MARK: init + + public init(d: Int = 0, + h: Int = 0, + m: Int = 0, + s: Int = 0, + f: Int = 0, + sf: Int = 0) + { + self.d = d + self.h = h + self.m = m + self.s = s + self.f = f + self.sf = sf + } + + } + +} + +extension OTTimecode.Components: Equatable { + + public static func ==(lhs: Self, rhs: Self) -> Bool { + return lhs.d == rhs.d && + lhs.h == rhs.h && + lhs.m == rhs.m && + lhs.s == rhs.s && + lhs.f == rhs.f && + lhs.sf == rhs.sf + } + +} + +extension OTTimecode.Components { + + /// Returns an instance of `OTTimecode(exactly:)`. + public func toTimecode(at frameRate: OTTimecode.FrameRate, + limit: OTTimecode.UpperLimit = ._24hours, + subFramesDivisor: Int? = nil) -> OTTimecode? + { + + if let sfd = subFramesDivisor { + + return OTTimecode(self, + at: frameRate, + limit: limit, + subFramesDivisor: sfd) + + } else { + + return OTTimecode(self, + at: frameRate, + limit: limit) + + } + } + + /// Returns an instance of `OTTimecode(rawValues:)`. + public func toTimecode(rawValuesAt frameRate: OTTimecode.FrameRate, + limit: OTTimecode.UpperLimit = ._24hours, + subFramesDivisor: Int? = nil) -> OTTimecode? + { + + if let sfd = subFramesDivisor { + + return OTTimecode(rawValues: self, + at: frameRate, + limit: limit, + subFramesDivisor: sfd) + + } else { + + return OTTimecode(rawValues: self, + at: frameRate, + limit: limit) + + } + } + +} diff --git a/Sources/SwiftTimecode/Data Interchange/OTTimecode Components.swift b/Sources/SwiftTimecode/Data Interchange/OTTimecode Components.swift new file mode 100644 index 00000000..b0346a98 --- /dev/null +++ b/Sources/SwiftTimecode/Data Interchange/OTTimecode Components.swift @@ -0,0 +1,86 @@ +// +// OTTimecode Components.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + /// Timecode components. + /// When setting, invalid values will cause the setter to fail silently. (Validation is based on the frame rate and `upperLimit` property.) + public var components: Components { + get { + return Components(d: days, + h: hours, + m: minutes, + s: seconds, + f: frames, + sf: subFrames) + } + set { + _ = setTimecode(exactly: newValue) + } + } + + /** Set timecode from tuple values. + Returns true/false depending on whether the string values are valid or not. + Values which are out-of-bounds will return false. (Validation is based on the frame rate and `upperLimit` property.) + */ + @discardableResult + public mutating func setTimecode(exactly values: Components) -> Bool { + + guard values.invalidComponents(at: frameRate, + limit: upperLimit, + subFramesDivisor: subFramesDivisor).count == 0 + else { return false } + + days = values.d + hours = values.h + minutes = values.m + seconds = values.s + frames = values.f + subFrames = values.sf + + return true + } + + /** Set timecode from tuple values. + (Validation is based on the frame rate and `upperLimit` property.) + */ + public mutating func setTimecode(clamping values: Components) { + days = values.d + hours = values.h + minutes = values.m + seconds = values.s + frames = values.f + subFrames = values.sf + + clampComponents() + } + + /** Set timecode from tuple values. + Timecode will wrap if out-of-bounds. Will handle negative values and wrap accordingly. (Wrapping is based on the frame rate and `upperLimit` property.) + */ + public mutating func setTimecode(wrapping values: Components) { + // guaranteed to work so we can ignore the value returned + + _ = setTimecode(exactly: __add(wrapping: values, to: Components(f: 0))) + } + + /** Set timecode from tuple values. + Timecode values will not be validated or rejected if they overflow. + */ + public mutating func setTimecode(rawValues values: Components) { + days = values.d + hours = values.h + minutes = values.m + seconds = values.s + frames = values.f + subFrames = values.sf + } + +} diff --git a/Sources/SwiftTimecode/Data Interchange/OTTimecode Real Time.swift b/Sources/SwiftTimecode/Data Interchange/OTTimecode Real Time.swift new file mode 100644 index 00000000..e5df0411 --- /dev/null +++ b/Sources/SwiftTimecode/Data Interchange/OTTimecode Real Time.swift @@ -0,0 +1,87 @@ +// +// OTTimecode Real Time.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2018-07-20. +// Copyright © 2018 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + /// Returns the current timecode converted to a duration in real-time milliseconds (wall-clock time), based on the frame rate. Value is returned as a Double so a high level of precision can be maintained. + /// Generally, `.realTime` -> `setTimecode(from: OTTime)` will produce equivalent results where 'from timecode' will == 'out timecode'. + /// When setting, invalid values will cause the setter to fail silently. (Validation is based on the frame rate and `upperLimit` property.) + public var realTime: OTTime { + + get { + var calc = Double(totalElapsedFrames) * (1000.0 / frameRate.frameRateForRealTimeCalculation) + + // over-estimate so real time is just past the equivalent timecode + // so calculations of real time back into timecode work reliably + // otherwise, this math produces a real time value that can be a hair under the actual elapsed real time that would trigger the equivalent timecode + + calc += 0.00001 + + return OTTime(ms: calc) + } + + set { + _ = setTimecode(from: newValue) + } + + } + + /// Sets the timecode to the nearest frame at the current frame rate from real-time milliseconds. + /// Returns false if it underflows or overflows valid timecode range. + @discardableResult + public mutating func setTimecode(from realTimeValue: OTTime) -> Bool { + + // the basic calculation + var calc = realTimeValue.ms / (1000.0 / frameRate.frameRateForRealTimeCalculation) + + // over-estimate so real time is just past the equivalent timecode + // so calculations of real time back into timecode work reliably + // otherwise, this math produces a real time value that can be a hair under the actual elapsed real time that would trigger the equivalent timecode + + calc += 0.0006 + + // final calculation + + let elapsedFrames = calc + let convertedComponents = Self.components(from: elapsedFrames, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + return setTimecode(exactly: convertedComponents) + + } + +} + +extension OTTime { + + /// Convenience method to create an `OTTimecode` struct using the default `(_ exactly:)` initializer. + public func toTimecode(at frameRate: OTTimecode.FrameRate, + limit: OTTimecode.UpperLimit = ._24hours, + subFramesDivisor: Int? = nil) -> OTTimecode? + { + if let sfd = subFramesDivisor { + + return OTTimecode(self, + at: frameRate, + limit: limit, + subFramesDivisor: sfd) + + } else { + + return OTTimecode(self, + at: frameRate, + limit: limit) + + } + + } + +} diff --git a/Sources/SwiftTimecode/Data Interchange/OTTimecode Samples.swift b/Sources/SwiftTimecode/Data Interchange/OTTimecode Samples.swift new file mode 100644 index 00000000..ea101f9c --- /dev/null +++ b/Sources/SwiftTimecode/Data Interchange/OTTimecode Samples.swift @@ -0,0 +1,137 @@ +// +// OTTimecode Samples.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + /// (Lossy) + /// Returns the current timecode converted to a duration in real-time audio samples at the given sample rate, rounded to the nearest sample. + /// Sample rate must be expressed as an Integer of Hz (ie: 48KHz would be passed as 48000) + public func samplesValue(atSampleRate: Int) -> Double { + + // prepare coefficients + + var fRate = frameRate.frameRateForElapsedFramesCalculation + + if frameRate.isDrop + && frameRate != ._30_drop + && frameRate != ._60_drop + && frameRate != ._120_drop { + // all dropframe rates require this except 30 DF and its multiples + fRate = Double(frameRate.maxFrames) / 1.001 + } + + var offset = 1.0 + switch frameRate { + case ._23_976, + ._24_98, + ._29_97, + ._47_952, + ._59_94, + ._119_88: + offset = 1.001 // not sure why this works, but it makes for an accurate calculation + + case ._24, + ._25, + ._29_97_drop, + ._30, + ._48, + ._50, + ._59_94_drop, + ._60, + ._100, + ._119_88_drop, + ._120: + break + + case ._30_drop, + ._60_drop, + ._120_drop: + offset = 0.999 + + } + + // perform calculation + + var dbl = totalElapsedFrames * (Double(atSampleRate) / fRate * offset) + + // over-estimate so samples are just past the equivalent timecode + // so calculations of samples back into timecode work reliably + // otherwise, this math produces a samples value that can be a hair under the actual elapsed samples that would trigger the equivalent timecode + + dbl += 0.0001 + + return dbl + + } + + /// (Lossy) + /// Sets the timecode to the nearest frame at the current frame rate from elapsed audio samples. + /// Returns false if it underflows or overflows valid timecode range. + /// Sample rate must be expressed as an Integer of Hz (ie: 48KHz would be passed as 48000) + @discardableResult + public mutating func setTimecode(fromSamplesValue: Double, + atSampleRate: Int) -> Bool { + + // prepare coefficients + + var fRate = frameRate.frameRateForElapsedFramesCalculation + + if frameRate.isDrop + && frameRate != ._30_drop + && frameRate != ._60_drop + && frameRate != ._120_drop { + // all dropframe rates require this except 30 DF and its multiples + fRate = Double(frameRate.maxFrames) / 1.001 + } + + var offset = 1.0 + switch frameRate { + case ._23_976, + ._24_98, + ._29_97, + ._47_952, + ._59_94, + ._119_88: + offset = 1.001 + + case ._24, + ._25, + ._29_97_drop, + ._30, + ._48, + ._50, + ._59_94_drop, + ._60, + ._100, + ._119_88_drop, + ._120: + break + + case ._30_drop, + ._60_drop, + ._120_drop: + offset = 0.999 + + } + + // perform calculation + + let dbl = fromSamplesValue / (Double(atSampleRate) / fRate * offset) + + // then derive components + let convertedComponents = Self.components(from: dbl, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + return setTimecode(exactly: convertedComponents) + + } + +} diff --git a/Sources/SwiftTimecode/Data Interchange/OTTimecode String.swift b/Sources/SwiftTimecode/Data Interchange/OTTimecode String.swift new file mode 100644 index 00000000..e54259b1 --- /dev/null +++ b/Sources/SwiftTimecode/Data Interchange/OTTimecode String.swift @@ -0,0 +1,337 @@ +// +// OTTimecode stringValue.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +#if os(macOS) +import Cocoa +#elseif os(iOS) || os(tvOS) || os(watchOS) +import UIKit +#endif + +extension OTTimecode { + + + // MARK: stringValue + + /** Timecode string representation. + + Valid formats for 24-hour: + + ``` + "00:00:00:00" "00:00:00;00" + "0:00:00:00" "0:00:00;00" + "00000000" + ``` + + Valid formats for 100-day: All of the above, as well as: + + ``` + "0 00:00:00:00" "0 00:00:00;00" + "0:00:00:00:00" "0:00:00:00;00" + ``` + + When setting, an improperly formatted timecode string or one with invalid values will cause the setter to fail silently. (Validation is based on the frame rate and `upperLimit` property.) + */ + public var stringValue: String { + get { + let sepDays = " " + let sepMain = ":" + let sepFrames = frameRate.isDrop ? ";" : ":" + let sepSubFrames = "." + + var output = "" + + output += "\(days != 0 ? "\(days)\(sepDays)" : "")" + output += "\(String(format: "%02d", hours ))\(sepMain)" + output += "\(String(format: "%02d", minutes))\(sepMain)" + output += "\(String(format: "%02d", seconds))\(sepFrames)" + output += "\(String(format: "%0\(frameRate.numberOfDigits)d", frames))" + + if displaySubFrames { + let numberOfSubFramesDigits = validRange(of: .subFrames).upperBound.numberOfDigits + + output += "\(sepSubFrames)\(String(format: "%0\(numberOfSubFramesDigits)d", subFrames))" + } + + return output + } + set { + _ = setTimecode(exactly: newValue) + } + } + + /// Forms `.stringValue` using filename-compatible characters. + public var stringValueFileNameCompatible: String { + let result = stringValue + .replacingOccurrences(of: ":", with: "-") + .replacingOccurrences(of: ";", with: "-") + .replacingOccurrences(of: " ", with: "-") + + return result + } + + // MARK: stringValueValidated + + /// Returns `stringValue` as `NSAttributedString`, highlighting invalid values. + /// + /// `invalidAttributes` are the `NSAttributedString` attributes applied to invalid values. + /// If `invalidAttributes` are not passed, the default of red forground color is used. + public func stringValueValidated(invalidAttributes: [NSAttributedString.Key : Any]? = nil, + withDefaultAttributes attrs: [NSAttributedString.Key : Any]? = nil) -> NSAttributedString + { + let sepDays = NSAttributedString(string: " ", attributes: attrs) + let sepMain = NSAttributedString(string: ":", attributes: attrs) + let sepFrames = NSAttributedString(string: frameRate.isDrop ? ";" : ":", attributes: attrs) + let sepSubFrames = NSAttributedString(string: ".", attributes: attrs) + + #if os(macOS) + let invalidColor = invalidAttributes + ?? [NSAttributedString.Key.foregroundColor : NSColor.red] + #elseif os(iOS) || os(tvOS) || os(watchOS) + let invalidColor = invalidAttributes + ?? [NSAttributedString.Key.foregroundColor : UIColor.red] + #endif + + let invalids = invalidComponents + + let output = NSMutableAttributedString(string: "", attributes: attrs) + + var piece: NSMutableAttributedString + + // days + if days != 0 { + piece = NSMutableAttributedString(string: "\(days)", attributes: attrs) + if invalids.contains(.days) { + piece.addAttributes(invalidColor, range: NSRange(location: 0, length: piece.string.count)) + } + + output.append(piece) + + output.append(sepDays) + + } + + // hours + + piece = NSMutableAttributedString(string: String(format: "%02d", hours), + attributes: attrs) + if invalids.contains(.hours) { + piece.addAttributes(invalidColor, range: NSRange(location: 0, length: piece.string.count)) + } + + output.append(piece) + + output.append(sepMain) + + // minutes + + piece = NSMutableAttributedString(string: String(format: "%02d", minutes), + attributes: attrs) + if invalids.contains(.minutes) { + piece.addAttributes(invalidColor, range: NSRange(location: 0, length: piece.string.count)) + } + + output.append(piece) + + output.append(sepMain) + + // seconds + + piece = NSMutableAttributedString(string: String(format: "%02d", seconds), + attributes: attrs) + if invalids.contains(.seconds) { + piece.addAttributes(invalidColor, range: NSRange(location: 0, length: piece.string.count)) + } + + output.append(piece) + + output.append(sepFrames) + + // frames + + piece = NSMutableAttributedString(string: + String(format: "%0\(frameRate.numberOfDigits)d", frames), + attributes: attrs) + if invalids.contains(.frames) { + piece.addAttributes(invalidColor, range: NSRange(location: 0, length: piece.string.count)) + } + + output.append(piece) + + // subframes + + if displaySubFrames { + let numberOfSubFramesDigits = validRange(of: .subFrames).upperBound.numberOfDigits + + output.append(sepSubFrames) + + piece = NSMutableAttributedString(string: + String(format: "%0\(numberOfSubFramesDigits)d", subFrames), + attributes: attrs) + if invalids.contains(.subFrames) { + piece.addAttributes(invalidColor, range: NSRange(location: 0, length: piece.string.count)) + } + + output.append(piece) + } + + return output + } + +} + + +// MARK: Setters + +extension OTTimecode { + + /** Returns true/false depending on whether the string is formatted correctly or not. + Values which are out-of-bounds will be clamped to minimum or maximum possible values. (Clamping is based on the frame rate and `upperLimit` property.) + */ + @discardableResult + public mutating func setTimecode(clamping string: String) -> Bool { + guard let tcVals = OTTimecode.decode(timecode: string) else { return false } + + setTimecode(clamping: tcVals) + + return true + } + + /** Returns true/false depending on whether the string is formatted correctly or not. + Values which are out-of-bounds will also cause the setter to fail, and return false. (Validation is based on the frame rate and `upperLimit` property.) + */ + @discardableResult + public mutating func setTimecode(exactly string: String) -> Bool { + guard let decoded = OTTimecode.decode(timecode: string) else { return false } + + return setTimecode(exactly: decoded) + } + + /** Returns true/false depending on whether the string is formatted correctly or not. + Values which are out-of-bounds will be clamped to minimum or maximum possible values. (Clamping is based on the frame rate and `upperLimit` property.) + */ + @discardableResult + public mutating func setTimecode(wrapping string: String) -> Bool { + guard let tcVals = OTTimecode.decode(timecode: string) else { return false } + + setTimecode(wrapping: tcVals) + + return true + } + + /** Returns true/false depending on whether the string is formatted correctly or not. + Timecode values will not be validated or rejected if they overflow. + */ + @discardableResult + public mutating func setTimecode(rawValues string: String) -> Bool { + guard let tcVals = OTTimecode.decode(timecode: string) else { return false } + + setTimecode(rawValues: tcVals) + + return true + } + +} + +extension OTTimecode { + + /** Decodes a Timecode string into its component values, without validating. + + Returns nil only if the string is not formatted as expected; raw values themselves will be passed as-is. + + Valid formats for 24-hour: + + ``` + "00:00:00:00" "00:00:00;00" + "00:00:00:00.00" "00:00:00;00.00" + ``` + + Valid formats for 100-day: All of the above, as well as: + + ``` + "0 00:00:00:00" "0 00:00:00;00" + "0:00:00:00:00" "0:00:00:00;00" + "0 00:00:00:00.00" "0 00:00:00;00.00" + "0:00:00:00:00.00" "0:00:00:00;00.00" + ``` + */ + public static func decode(timecode string: String) -> Components? { + + let pattern = #"^(\d+)??[\:\s]??(\d+)[\:](\d+)[\:](\d+)[\:\;](\d+)[\.]??(\d+)??$"# + + let matches = string.regexMatches(captureGroupsFromPattern: pattern) + + // map Strings to Int + + let ints = matches.map { $0 == nil ? nil : Int($0!) } + + // basic sanity check - ensure there's at least 4 values + + let nonNilCount = ints.filter { $0 != nil }.count + + guard (4...6).contains(nonNilCount) else { return nil } + + // return components + + return Components(d: ints[0] ?? 0, + h: ints[1] ?? 0, + m: ints[2] ?? 0, + s: ints[3] ?? 0, + f: ints[4] ?? 0, + sf: ints[5] ?? 0) + + // old code (pre-subframes) + +// var tcElements: [Substring] = ["","","","",""] +// +// // check for 8 digits with no separators +// if string.count == 8 && +// CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string)) { +// // get units from 4 x 2 digit pairs +// tcElements[0] = "00" // empty Days +// tcElements[1] = string[string.startIndex...string.index(string.startIndex, offsetBy: 1)] +// tcElements[2] = string[string.index(string.startIndex, offsetBy: 2)...string.index(string.startIndex, offsetBy: 3)] +// tcElements[3] = string[string.index(string.startIndex, offsetBy: 4)...string.index(string.startIndex, offsetBy: 5)] +// tcElements[4] = string[string.index(string.startIndex, offsetBy: 6)...string.index(string.startIndex, offsetBy: 7)] +// +// } else if 10...14 ~= string.count { // minimum place widths: 0:00:00:00 +// // attempt to split number components; not assuming they're numbers yet, if they can be converted to integers after this then that's validation enough +// tcElements = string.split(whereSeparator: { $0 == ":" || $0 == ";" || $0 == "." }) +// guard 4...5 ~= tcElements.count else { return nil } // if days are separated by : ; or . then this will be 5 +// +// if tcElements.count == 4 { // if 4, check to see if Days are included and separated by a space +// let getDays = tcElements[0].split(separator: " ") +// switch getDays.count { +// case 1: // Days not present +// tcElements = [Substring("00")] + tcElements // add empty Days buffer +// case 2: // We can maybe assume there's Day and Hours +// tcElements = [getDays[0]] + tcElements // add Days +// tcElements[1] = getDays[1] // assign Hours +// default: return nil +// } +// } +// +// } else { +// // failed formatting validation +// return nil +// +// } +// +// // ensure all values can be converted to a number; this will weed out any non-numerical elements +// guard let d = Int(tcElements[0]), +// let h = Int(tcElements[1]), +// let m = Int(tcElements[2]), +// let s = Int(tcElements[3]), +// let f = Int(tcElements[4]) else { return nil } +// +// return Components(d: d, h: h, m: m, s: s, f: f) + + } + +} diff --git a/Sources/SwiftTimecode/Formatter/TextFormatter.swift b/Sources/SwiftTimecode/Formatter/TextFormatter.swift new file mode 100644 index 00000000..6c5d5781 --- /dev/null +++ b/Sources/SwiftTimecode/Formatter/TextFormatter.swift @@ -0,0 +1,364 @@ +// +// TextFormatter.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-07-11. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +#if os(macOS) +import Cocoa +#elseif os(iOS) || os(tvOS) || os(watchOS) +import UIKit +#endif + +// MARK: - TextFormatter + +extension OTTimecode { + + /// Formatter subclass + /// (Used in OTTimecode.TextField) + @objc(OTTimecodeTextFormatter) + public class TextFormatter: Formatter { + + // MARK: properties + + public var frameRate: OTTimecode.FrameRate? + public var upperLimit: OTTimecode.UpperLimit? + public var displaySubFrames: Bool? + public var subFramesDivisor: Int? + + /// The formatter's `attributedString(...) -> NSAttributedString` output will override a control's alignment (ie: `NSTextField`). + /// Setting alignment here will add the appropriate paragraph alignment attribute to the output `NSAttributedString`. + public var alignment: NSTextAlignment = .natural + + /// When set true, invalid timecode component values are individually attributed. + public var showsValidation: Bool = false + + /// The `NSAttributedString` attributes applied to invalid values if `showsValidation` is set. + /// + /// Defaults to red foreground color. + + public var validationAttributes: [NSAttributedString.Key : Any] + = { + #if os(macOS) + return [ .foregroundColor : NSColor.red ] + #elseif os(iOS) || os(tvOS) || os(watchOS) + return [ .foregroundColor : UIColor.red ] + #endif + }() + + + // MARK: init + + public required init?(coder: NSCoder) { + super.init(coder: coder) + } + + public init(frameRate: OTTimecode.FrameRate? = nil, + limit: OTTimecode.UpperLimit? = nil, + displaySubFrames: Bool? = nil, + subFramesDivisor: Int? = nil, + showsValidation: Bool = false, + validationAttributes: [NSAttributedString.Key : Any]? = nil) + { + super.init() + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + self.displaySubFrames = displaySubFrames + + self.showsValidation = showsValidation + + if validationAttributes != nil { + self.validationAttributes = validationAttributes! + } + } + + /// Initializes with properties from an `OTTimecode` object. + public convenience init(using timecode: OTTimecode, + showsValidation: Bool = false, + validationAttributes: [NSAttributedString.Key : Any]? = nil) + { + self.init(frameRate: timecode.frameRate, + limit: timecode.upperLimit, + displaySubFrames: timecode.displaySubFrames, + subFramesDivisor: timecode.subFramesDivisor, + showsValidation: showsValidation, + validationAttributes: validationAttributes) + } + + + public func inheritProperties(from other: OTTimecode.TextFormatter) { + self.frameRate = other.frameRate + self.upperLimit = other.upperLimit + self.subFramesDivisor = other.subFramesDivisor + self.displaySubFrames = other.displaySubFrames + + self.alignment = other.alignment + self.showsValidation = other.showsValidation + self.validationAttributes = other.validationAttributes + } + + // MARK: - Override methods + + + + // MARK: string + + public override func string(for obj: Any?) -> String? { + + guard let string = obj as? String + else { return nil } + + guard var tc = timecodeWithProperties + else { return string } + + // form timecode components without validating + guard let tcc = OTTimecode.decode(timecode: string) + else { return string } + + // set values without validating + tc.setTimecode(rawValues: tcc) + + return tc.stringValue + + } + + + // MARK: attributedString + + public override func attributedString(for obj: Any, + withDefaultAttributes attrs: [NSAttributedString.Key : Any]? = nil) -> NSAttributedString? { + + guard let string = string(for: obj) + else { return nil } + + func entirelyInvalid() -> NSAttributedString { + return showsValidation + ? NSAttributedString(string: string, + attributes: validationAttributes + .merging(attrs ?? [:], uniquingKeysWith: { (current, _) in current })) + .addingAttribute(alignment: self.alignment) + : NSAttributedString(string: string, attributes: attrs) + .addingAttribute(alignment: self.alignment) + } + + // grab properties from the formatter + guard var tc = timecodeWithProperties + else { return entirelyInvalid() } + + // form timecode components without validating + guard let tcc = OTTimecode.decode(timecode: string) + else { return entirelyInvalid() } + + // set values without validating + tc.setTimecode(rawValues: tcc) + + return + ( + showsValidation + ? tc.stringValueValidated(invalidAttributes: validationAttributes, + withDefaultAttributes: attrs) + : NSAttributedString(string: string, attributes: attrs) + ) + .addingAttribute(alignment: self.alignment) + + } + + public override func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer?, + for string: String, + errorDescription error: AutoreleasingUnsafeMutablePointer?) -> Bool + { + obj?.pointee = string as NSString + return true + } + + + // MARK: isPartialStringValid + + public override func isPartialStringValid(_ partialStringPtr: AutoreleasingUnsafeMutablePointer, + proposedSelectedRange proposedSelRangePtr: NSRangePointer?, + originalString origString: String, + originalSelectedRange origSelRange: NSRange, + errorDescription error: AutoreleasingUnsafeMutablePointer?) -> Bool + { + guard let frameRate = frameRate else { return true } + guard let limit = upperLimit else { return true } + //guard let subFramesDivisor = subFramesDivisor else { return true } + guard let displaySubFrames = displaySubFrames else { return true } + + let partialString = partialStringPtr.pointee as String + + // sanity checks + + if partialString.isEmpty { return true } // allow empty field + //if partialString.count > 20 { return false } // don't allow too many chars + + // constants + + let numberChars = CharacterSet(charactersIn: "0123456789") + //let coreSeparatorChars = CharacterSet(charactersIn: ":;") + //let allSeparatorChars = CharacterSet(charactersIn: ":;. ") + + let allowedChars = CharacterSet(charactersIn: "0123456789:;. ") + let disallowedChars = allowedChars.inverted + + // more sanity checks + + if let _ = partialString.rangeOfCharacter(from: disallowedChars, + options: .caseInsensitive) + { + error?.pointee = NSString("Invalid characters.") + return false + } + + // parse + + var string = "" + var fixed = false + + var consecutiveIntCount = 0 + var intGrouping = 0 + var spaceCount = 0 + var colonCount = 0 + var periodCount = 0 + var lastChar: Character? = nil + + for var char in partialString { + + // prep + + let originalChar = char ; _ = originalChar + + if numberChars.contains(char) + { consecutiveIntCount += 1 } + + // separators + + switch char { + case ".": + if colonCount < 3 { + char = frameRate.isDrop && (colonCount == 2) + ? ";" : ":" + + fixed = true + } + else if periodCount == 0 { break } + else { return false } + + case ";": + if colonCount < 3 { + char = frameRate.isDrop && (colonCount == 2) + ? ";" : ":" + + fixed = true + } + + default: break + } + + if char == " " { + if limit == ._24hours + { return false } + + if !(intGrouping == 1 && spaceCount == 0 && colonCount == 0 && periodCount == 0) + { return false } + + spaceCount += 1 + } + + // // don't allow two separators in a row + // + // if allSeparatorChars.containsUnicodeScalars(of: char), + // lastChar != nil, + // allSeparatorChars.containsUnicodeScalars(of: lastChar!) + // { return false } + + // separator validation + + if (char == ":" || char == ";") + { colonCount += 1 ; consecutiveIntCount = 0 } + + if (char == ":" || char == ";") && colonCount >= 4 + { return false } + + // period validation + + if char == "." + { periodCount += 1 } + + if char == "." && periodCount > 1 + { return false } + + if char == "." && !displaySubFrames + { return false } + + // number validation (?) + + + // cleanup + + if numberChars.contains(char) { + if lastChar != nil { + if !numberChars.contains(lastChar!) + { intGrouping += 1 } + } else { + intGrouping += 1 + } + } + + // cycle variables + + lastChar = char + + // append char + + string += "\(char)" + + } + + if fixed { + + partialStringPtr.pointee = NSString(string: string) + + return false + + } else { + + return true + + } + + } + + } + +} + + +// MARK: timecodeWithProperties + +extension OTTimecode.TextFormatter { + + public var timecodeWithProperties: OTTimecode? { + + guard let frameRate = frameRate else { return nil } + guard let upperLimit = upperLimit else { return nil } + guard let subFramesDivisor = subFramesDivisor else { return nil } + guard let displaySubFrames = displaySubFrames else { return nil } + + var tc = OTTimecode(at: frameRate, + limit: upperLimit, + subFramesDivisor: subFramesDivisor) + + tc.displaySubFrames = displaySubFrames + + return tc + + } + +} diff --git a/Sources/SwiftTimecode/FrameRate/FrameRate Properties.swift b/Sources/SwiftTimecode/FrameRate/FrameRate Properties.swift new file mode 100644 index 00000000..e01d1fdb --- /dev/null +++ b/Sources/SwiftTimecode/FrameRate/FrameRate Properties.swift @@ -0,0 +1,303 @@ +// +// FrameRate Properties.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + + +// MARK: stringValue ... + +extension OTTimecode.FrameRate { + + /// Returns human-readable frame rate string. + public var stringValue: String { + switch self { + case ._23_976: return "23.976 NDF" + case ._24: return "24 NDF" + case ._24_98: return "24.98 NDF" + case ._25: return "25 NDF" + case ._29_97: return "29.97 NDF" + case ._29_97_drop: return "29.97 DF" + case ._30: return "30 NDF" + case ._30_drop: return "30 DF" + case ._47_952: return "47.952 NDF" + case ._48: return "48 NDF" + case ._50: return "50 NDF" + case ._59_94: return "59.94 NDF" + case ._59_94_drop: return "59.94 DF" + case ._60: return "60 NDF" + case ._60_drop: return "60 DF" + case ._100: return "100 NDF" + case ._119_88: return "119.88 NDF" + case ._119_88_drop: return "119.88 DF" + case ._120: return "120 NDF" + case ._120_drop: return "120 DF" + } + } + + /// Initializes from a `stringValue` string. Case-sensitive. + public init?(stringValue: String) { + if let findMatch = Self.allCases.first(where: { $0.stringValue == stringValue }) { + self = findMatch + } else { + return nil + } + } + + /// Returns human-readable frame rate string, ie: suitable for use in filenames. + public var stringValueFileNameCompatible: String { + switch self { + case ._23_976: return "23_976NDF" + case ._24: return "24NDF" + case ._24_98: return "24_98DNF" + case ._25: return "25NDF" + case ._29_97: return "29_97NDF" + case ._29_97_drop: return "29_97DF" + case ._30: return "30NDF" + case ._30_drop: return "30DF" + case ._47_952: return "47_952NDF" + case ._48: return "48NDF" + case ._50: return "50NDF" + case ._59_94: return "59_94NDF" + case ._59_94_drop: return "59_94DF" + case ._60: return "60NDF" + case ._60_drop: return "60DF" + case ._100: return "100NDF" + case ._119_88: return "119_88NDF" + case ._119_88_drop: return "119_88DF" + case ._120: return "120NDF" + case ._120_drop: return "120DF" + } + } + +} + + +// MARK: Public meta properties + +extension OTTimecode.FrameRate { + + /// Returns true if frame rate is drop-frame. + public var isDrop: Bool { + switch self { + case ._23_976: return false + case ._24: return false + case ._24_98: return false + case ._25: return false + case ._29_97: return false + case ._29_97_drop: return true + case ._30: return false + case ._30_drop: return true + case ._47_952: return false + case ._48: return false + case ._50: return false + case ._59_94: return false + case ._59_94_drop: return true + case ._60: return false + case ._60_drop: return true + case ._100: return false + case ._119_88: return false + case ._119_88_drop: return true + case ._120: return false + case ._120_drop: return true + } + } + + /// Total number of elapsed frames that comprise 1 second of timecode. + public var maxFrames: Int { + switch self { + case ._23_976: return 24 + case ._24: return 24 + case ._24_98: return 25 + case ._25: return 25 + case ._29_97: return 30 + case ._29_97_drop: return 30 + case ._30: return 30 + case ._30_drop: return 30 + case ._47_952: return 48 + case ._48: return 48 + case ._50: return 50 + case ._59_94: return 60 + case ._59_94_drop: return 60 + case ._60: return 60 + case ._60_drop: return 60 + case ._100: return 100 + case ._119_88: return 120 + case ._119_88_drop: return 120 + case ._120: return 120 + case ._120_drop: return 120 + } + } + + /// Max frame number displayable before seconds roll over. + public var maxFrameNumberDisplayable: Int { + return maxFrames - 1 + } + + /// Returns max elapsed frames from 0 to and including rolling over to `extent`. + public func maxTotalFrames(in extent: OTTimecode.UpperLimit) -> Int { + // template to calculate: + // Int(Double(extent.maxDays) * 24 * 60 * 60 * self.frameRateForCalculation) + + switch extent { + case ._24hours: + switch self { + case ._23_976: return 2073600 // @ 24hours + case ._24: return 2073600 // @ 24hours + case ._24_98: return 2160000 // @ 24hours + case ._25: return 2160000 // @ 24hours + case ._29_97: return 2592000 // @ 24hours + case ._29_97_drop: return 2589408 // @ 24hours + case ._30: return 2592000 // @ 24hours + case ._30_drop: return 2589408 // @ 24hours + case ._47_952: return 4147200 // @ 24hours + case ._48: return 4147200 // @ 24hours + case ._50: return 4320000 // @ 24hours + case ._59_94: return 5184000 // @ 24hours (_29_97 * 2 in theory) + case ._59_94_drop: return 5178816 // @ 24hours (_29_97_drop * 2, in theory) + case ._60: return 5184000 // @ 24hours + case ._60_drop: return 5178816 // @ 24hours + case ._100: return 8640000 // @ 24hours + case ._119_88: return 10368000 // @ 24hours (_29_97 * 4 in theory) + case ._119_88_drop: return 10357632 // @ 24hours (_29_97_drop * 4, in theory) + case ._120: return 10368000 // @ 24hours + case ._120_drop: return 10357632 // @ 24hours + } + + case ._100days: + return self.maxTotalFrames(in: ._24hours) * extent.maxDays + } + + } + + /// Returns max elapsed frames possible before rolling over to 0. + /// (Number of frames from 0 to `extent` minus one subframe). + public func maxTotalFramesExpressible(in extent: OTTimecode.UpperLimit) -> Int { + return maxTotalFrames(in: extent) - 1 + } + +} + + +// MARK: Internal properties + +extension OTTimecode.FrameRate { + + /// Internal use. + internal var frameRateForElapsedFramesCalculation: Double { + switch self { + case ._23_976: return 24.0 + case ._24: return 24.0 + case ._24_98: return 25.0 + case ._25: return 25.0 + case ._29_97: return 30.0 + case ._29_97_drop: return 29.97 // used in special drop-frame calculation + case ._30: return 30.0 + case ._30_drop: return 29.97 + case ._47_952: return 48.0 + case ._48: return 48.0 + case ._50: return 50.0 + case ._59_94: return 60.0 + case ._59_94_drop: return 59.94 // used in special drop-frame calculation + case ._60: return 60.0 + case ._60_drop: return 59.94 // used in special drop-frame calculation + case ._100: return 100.0 + case ._119_88: return 120.0 + case ._119_88_drop: return 119.88 // used in special drop-frame calculation + case ._120: return 120.0 + case ._120_drop: return 119.88 // used in special drop-frame calculation + } + } + + /// Internal use. Used in marker MIDI file export. + internal var frameRateForRealTimeCalculation: Double { + switch self { + case ._23_976: return 24.0 / 1.001 // confirmed correct + case ._24: return 24.0 // confirmed correct + case ._24_98: return 25.0 / 1.001 + case ._25: return 25.0 // confirmed correct + case ._29_97: return 30.0 / 1.001 // confirmed correct + case ._29_97_drop: return 30.0 / 1.001 // confirmed correct + case ._30: return 30.0 // confirmed correct + case ._30_drop: return 30.0 / 1.001 + case ._47_952: return 48.0 / 1.001 + case ._48: return 48.0 + case ._50: return 50.0 // confirmed correct + case ._59_94: return 60.0 / 1.001 // confirmed correct + case ._59_94_drop: return 60.0 / 1.001 // confirmed correct + case ._60: return 60.0 // confirmed correct + case ._60_drop: return 60.0 / 1.001 + case ._100: return 100.0 + case ._119_88: return 120.0 / 1.001 + case ._119_88_drop: return 120.0 / 1.001 + case ._120: return 120.0 + case ._120_drop: return 120.0 / 1.001 + } + } + + /// Internal use. + internal var framesDroppedPerMinute: Double { + switch self { + case ._29_97_drop: return 2.0 + case ._30_drop: return 2.0 + case ._59_94_drop: return 4.0 + case ._60_drop: return 4.0 + case ._119_88_drop: return 8.0 + case ._120_drop: return 8.0 + + case ._23_976, + ._24, + ._24_98, + ._25, + ._29_97, + ._30, + ._47_952, + ._48, + ._50, + ._59_94, + ._60, + ._100, + ._119_88, + ._120: + + // this value is not actually used + // this is only here so that when adding frame rates to the framework, the compiler will throw an error to remind you to add the enum case here + return 0.0 + + } + + } + + /// Returns the number of digits required for frames within the timecode string. + /// + /// ie: 24 fps would return 2, but 120 fps would return 3. + public var numberOfDigits: Int { + switch self { + case ._23_976: return 2 + case ._24: return 2 + case ._24_98: return 2 + case ._25: return 2 + case ._29_97: return 2 + case ._29_97_drop: return 2 + case ._30: return 2 + case ._30_drop: return 2 + case ._47_952: return 2 + case ._48: return 2 + case ._50: return 2 + case ._59_94: return 2 + case ._59_94_drop: return 2 + case ._60: return 2 + case ._60_drop: return 2 + case ._100: return 3 + case ._119_88: return 3 + case ._119_88_drop: return 3 + case ._120: return 3 + case ._120_drop: return 3 + } + } + +} diff --git a/Sources/SwiftTimecode/FrameRate/FrameRate String Extensions.swift b/Sources/SwiftTimecode/FrameRate/FrameRate String Extensions.swift new file mode 100644 index 00000000..93e01c69 --- /dev/null +++ b/Sources/SwiftTimecode/FrameRate/FrameRate String Extensions.swift @@ -0,0 +1,20 @@ +// +// FrameRate String Extensions.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-08-18. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension String { + + /// Convenience method to call `OTTimecode.FrameRate(stringValue: self)` + public var toFrameRate: OTTimecode.FrameRate? { + + OTTimecode.FrameRate(stringValue: self) + + } + +} diff --git a/Sources/SwiftTimecode/FrameRate/FrameRate.swift b/Sources/SwiftTimecode/FrameRate/FrameRate.swift new file mode 100644 index 00000000..b255a295 --- /dev/null +++ b/Sources/SwiftTimecode/FrameRate/FrameRate.swift @@ -0,0 +1,135 @@ +// +// FrameRate.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2018-07-20. +// Copyright © 2018 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + // MARK: - FrameRate + + public enum FrameRate: Int { + + /// 23.976 fps (aka 23.98) + /// + /// Also known as 24p for HD video, sometimes rounded up to 23.98 fps. started out as the format for dealing with 24fps film in a NTSC post environment. + case _23_976 + + /// 24 fps + /// + /// (film, ATSC, 2k, 4k, 6k) + case _24 + + /// 24.98 fps + /// + /// This frame rate is commonly used to facilitate transfers between PAL and NTSC video and film sources. It is mostly used to compensate for some error. + case _24_98 + + /// 25 fps + /// + /// (PAL, used in Europe, Uruguay, Argentina, Australia), SECAM, DVB, ATSC) + case _25 + + /// 29.97 fps (30p) + /// + /// (NTSC American System (US, Canada, Mexico, Colombia, etc.), ATSC, PAL-M (Brazil)) + /// (30 / 1.001) frame/sec + case _29_97 + + /// 29.97 drop fps + case _29_97_drop + + /// 30 fps + /// + /// (ATSC) This is the frame count of NTSC broadcast video. However, the actual frame rate or speed of the video format runs at 29.97 fps. + /// + /// This timecode clock does not run in realtime. It is slightly slower by 0.1%. + /// ie: 1:00:00:00:00 at 30 fps is approx 1:00:00:00;02 in 29.97df + case _30 + + /// 30 drop fps: + /// + /// The 30 fps drop-frame count is an adaptation that allows a timecode display running at 29.97 fps to actually show the clock-on-the-wall-time of the timeline by “dropping” or skipping specific frame numbers in order to “catch the clock up” to realtime. + case _30_drop + + /// 47.952 (48p?) + /// + /// Double 23.976 fps + case _47_952 + + /// 48 fps + /// + /// Double 24 fps + case _48 + + /// 50 fps + /// + /// Double 25 fps + case _50 + + /// 59.94 fps + /// + /// Double 29.97 fps + /// + /// This video frame rate is supported by high definition cameras and is compatible with NTSC (29.97 fps). + case _59_94 + + /// 59.94 drop fps + /// + /// Double 29.97 drop fps + case _59_94_drop + + /// 60 fps + /// + /// Double 30 fps + /// + /// This video frame rate is supported by many high definition cameras. However, the NTSC compatible 59.94 fps frame rate is much more common. + case _60 + + /// 60 drop fps + /// + /// Double 30 fps + case _60_drop + + /// 100 fps + /// + /// Double 50 fps / quadruple 25 fps + case _100 + + /// 119.88 fps + /// + /// Double 59.94 fps / quadruple 29.97 fps + case _119_88 + + /// 119.88 drop fps + /// + /// Double 59.94 drop fps / quadruple 29.97 drop fps + case _119_88_drop + + /// 120 fps + /// + /// Double 60 fps / quadruple 30 fps + case _120 + + /// 120 drop fps + /// + /// Double 60 fps drop / quadruple 30 fps drop + case _120_drop + + } + +} + +extension OTTimecode.FrameRate: CaseIterable { + + /// All dropframe frame rates. + public static let allDrop: [Self] = allCases.filter { $0.isDrop } + + /// All non-dropframe frame rates. + public static let allNonDrop: [Self] = allCases.filter { !$0.isDrop } + +} diff --git a/Sources/SwiftTimecode/Math/OTTimecode Math Internal.swift b/Sources/SwiftTimecode/Math/OTTimecode Math Internal.swift new file mode 100644 index 00000000..5bcc6f19 --- /dev/null +++ b/Sources/SwiftTimecode/Math/OTTimecode Math Internal.swift @@ -0,0 +1,270 @@ +// +// OTTimecode Math Internal.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + // MARK: - Add + + /// Utility function to add a duration to a base timecode. Returns nil if it overflows possible timecode values. + internal func __add(exactly duration: Components, to base: Components) -> Components? { + + let tcOrigin = Self.totalElapsedFrames(of: base, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcAdd = Self.totalElapsedFrames(of: duration, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcNew = tcOrigin + tcAdd + + if tcNew > maxFramesAndSubframesExpressible { return nil } + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Utility function to add a duration to a base timecode. Clamps to maximum timecode expressible. + internal func __add(clamping duration: Components, to base: Components) -> Components { + + let tcOrigin = Self.totalElapsedFrames(of: base, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcAdd = Self.totalElapsedFrames(of: duration, + at: frameRate, + subFramesDivisor: subFramesDivisor) + var tcNew = tcOrigin + tcAdd + + tcNew = tcNew.clamped(to: 0.0...maxFramesAndSubframesExpressible) + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Utility function to add a duration to a base timecode. Wraps around the clock as set by the `upperLimit` property. + internal func __add(wrapping duration: Components, to base: Components) -> Components { + + let tcOrigin = Self.totalElapsedFrames(of: base, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcAdd = Self.totalElapsedFrames(of: duration, + at: frameRate, + subFramesDivisor: subFramesDivisor) + var tcNew = tcOrigin + tcAdd + + let wrapTest = tcNew.quotientAndRemainder(dividingBy: Double(frameRate.maxTotalFrames(in: upperLimit))) + + // check for a negative result and wrap accordingly + if tcNew < 0.0 { + tcNew = Double(frameRate.maxTotalFrames(in: upperLimit)) + wrapTest.remainder // wrap around + } else { + tcNew = wrapTest.remainder + } + + // TODO: - ***** can implement later: number of times the value wrapped will be stored in wrapTest.quotient + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + + // MARK: - Subtract + + /// Utility function to add a duration to a base timecode. Returns nil if overflows possible timecode values. + internal func __subtract(exactly duration: Components, from base: Components) -> Components? { + + let tcOrigin = Self.totalElapsedFrames(of: base, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcSubtract = Self.totalElapsedFrames(of: duration, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcNew = tcOrigin - tcSubtract + + if tcNew < 0 { return nil } + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Utility function to add a duration to a base timecode. Clamps to maximum timecode expressible. + internal func __subtract(clamping duration: Components, from base: Components) -> Components { + + let tcOrigin = Self.totalElapsedFrames(of: base, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcSubtract = Self.totalElapsedFrames(of: duration, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcNew = tcOrigin - tcSubtract + + if tcNew < 0 { return Components(d: 0, h: 0, m: 0, s: 0, f: 0) } + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Utility function to add a duration to a base timecode. Wraps around the clock as set by the `upperLimit` property. + internal func __subtract(wrapping duration: Components, from base: Components) -> Components { + + let tcOrigin = Self.totalElapsedFrames(of: base, + at: frameRate, + subFramesDivisor: subFramesDivisor) + let tcSubtract = Self.totalElapsedFrames(of: duration, + at: frameRate, + subFramesDivisor: subFramesDivisor) + var tcNew = tcOrigin - tcSubtract + + // TODO: - ***** can implement later: also return number of times the value wrapped + + let wrapTest = tcNew.quotientAndRemainder(dividingBy: Double(frameRate.maxTotalFrames(in: upperLimit))) + + if tcNew < 0 { + tcNew = Double(frameRate.maxTotalFrames(in: upperLimit)) + wrapTest.remainder // wrap around + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + } else { + return Self.components(from: wrapTest.remainder, + at: frameRate, + subFramesDivisor: subFramesDivisor) + } + + } + + + // MARK: - Multiply + + /// Utility function to multiply a base timecode by a duration. Returns nil if it overflows possible timecode values. + internal func __multiply(exactly duration: Double, with: Components) -> Components? { + + let tcOrigin = Double(Self.totalElapsedFrames(of: with, + at: frameRate, + subFramesDivisor: subFramesDivisor)) + let tcNew = Int(tcOrigin * duration) + + if tcNew > frameRate.maxTotalFramesExpressible(in: upperLimit) { return nil } + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Utility function to multiply a base timecode by a duration. Clamps to maximum timecode expressible. + internal func __multiply(clamping duration: Double, with: Components) -> Components { + + let tcOrigin = Double(Self.totalElapsedFrames(of: with, + at: frameRate, + subFramesDivisor: subFramesDivisor)) + var tcNew = Int(tcOrigin * duration) + + tcNew = tcNew.clamped(to: 0...frameRate.maxTotalFramesExpressible(in: upperLimit)) + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Utility function to multiply a base timecode by a duration. Wraps around the clock as set by the `upperLimit` property. + internal func __multiply(wrapping duration: Double, with: Components) -> Components { + + let tcOrigin = Double(Self.totalElapsedFrames(of: with, + at: frameRate, + subFramesDivisor: subFramesDivisor)) + var tcNew = Int(tcOrigin * duration) + + let wrapTest = tcNew.quotientAndRemainder(dividingBy: frameRate.maxTotalFrames(in: upperLimit)) + + // check for a negative result and wrap accordingly + if tcNew < 0 { + tcNew = frameRate.maxTotalFrames(in: upperLimit) + wrapTest.remainder // wrap around + } else { + tcNew = wrapTest.remainder + } + + // TODO: - ***** can implement later: number of times the value wrapped will be stored in wrapTest.quotient + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + + // MARK: - Divide + + /// Utility function to divide a base timecode by a duration. Returns nil if it overflows possible timecode values. + internal func __divide(exactly duration: Double, into: Components) -> Components? { + + let tcOrigin = Double(Self.totalElapsedFrames(of: into, + at: frameRate, + subFramesDivisor: subFramesDivisor)) + let tcNew = Int(tcOrigin / duration) + + if tcNew > frameRate.maxTotalFramesExpressible(in: upperLimit) { return nil } + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Utility function to divide a base timecode by a duration. Clamps to maximum timecode expressible. + internal func __divide(clamping duration: Double, into: Components) -> Components { + + let tcOrigin = Double(Self.totalElapsedFrames(of: into, + at: frameRate, + subFramesDivisor: subFramesDivisor)) + var tcNew = Int(tcOrigin / duration) + + tcNew = tcNew.clamped(to: 0...frameRate.maxTotalFramesExpressible(in: upperLimit)) + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Utility function to divide a base timecode by a duration. Wraps around the clock as set by the `upperLimit` property. + internal func __divide(wrapping duration: Double, into: Components) -> Components { + + let tcOrigin = Double(Self.totalElapsedFrames(of: into, + at: frameRate, + subFramesDivisor: subFramesDivisor)) + var tcNew = Int(tcOrigin / duration) + + let wrapTest = tcNew.quotientAndRemainder(dividingBy: frameRate.maxTotalFrames(in: upperLimit)) + + // check for a negative result and wrap accordingly + if tcNew < 0 { + tcNew = frameRate.maxTotalFrames(in: upperLimit) + wrapTest.remainder // wrap around + } else { + tcNew = wrapTest.remainder + } + + // TODO: - ***** can implement later: number of times the value wrapped will be stored in wrapTest.quotient + + return Self.components(from: tcNew, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + +} diff --git a/Sources/SwiftTimecode/Math/OTTimecode Math Public.swift b/Sources/SwiftTimecode/Math/OTTimecode Math Public.swift new file mode 100644 index 00000000..efc77794 --- /dev/null +++ b/Sources/SwiftTimecode/Math/OTTimecode Math Public.swift @@ -0,0 +1,270 @@ +// +// OTTimecode Math Public.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2018-07-20. +// Copyright © 2018 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + // MARK: - Add + + /// Add a duration to the current timecode. + /// Returns false if resulting value is not within valid timecode range. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public mutating func add(_ exactly: Components) -> Bool { + guard let newTC = __add(exactly: exactly, to: components) else { return false } + + return setTimecode(exactly: newTC) + } + + /// Add a duration to the current timecode. + /// Clamps to valid timecodes. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public mutating func add(clamping values: Components) { + let newTC = __add(clamping: values, to: components) + + _ = setTimecode(exactly: newTC) // guaranteed to work + } + + /// Add a duration to the current timecode. + /// Wraps around the clock as set by the `upperLimit` property. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public mutating func add(wrapping values: Components) { + let newTC = __add(wrapping: values, to: components) + + _ = setTimecode(exactly: newTC) // guaranteed to work + } + + /// Add a duration to the current timecode and return a new instance with the new timecode. + /// Returns nil if resulting value is not within valid timecode range. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func adding(_ exactly: Components) -> OTTimecode? { + guard let newTC = __add(exactly: exactly, to: components) else { return nil } + + var newOTTimecode = self // copy self + guard newOTTimecode.setTimecode(exactly: newTC) else { return nil } + + return newOTTimecode + } + + /// Add a duration to the current timecode and return a new instance with the new timecode. + /// Clamps to valid timecodes as set by the `upperLimit` property. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func adding(clamping values: Components) -> OTTimecode { + let newTC = __add(clamping: values, to: components) + + var newOTTimecode = self // copy self + _ = newOTTimecode.setTimecode(exactly: newTC) // guaranteed to work + + return newOTTimecode + } + + /// Add a duration to the current timecode and return a new instance with the new timecode. + /// Wraps around the clock as set by the `upperLimit` property. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func adding(wrapping values: Components) -> OTTimecode { + let newTC = __add(wrapping: values, to: components) + + var newOTTimecode = self // copy self + _ = newOTTimecode.setTimecode(exactly: newTC) // guaranteed to work + + return newOTTimecode + } + + + // MARK: - Subtract + + /// Subtract a duration from the current timecode. + /// Returns false if resulting value is not within valid timecode range. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public mutating func subtract(_ exactly: Components) -> Bool { + guard let newTC = __subtract(exactly: exactly, from: components) else { return false } + + return setTimecode(exactly: newTC) + } + + /// Subtract a duration from the current timecode. + /// Clamps to valid timecodes. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public mutating func subtract(clamping: Components) { + let newTC = __subtract(clamping: clamping, from: components) + + _ = setTimecode(exactly: newTC) + } + + /// Subtract a duration from the current timecode. + /// Wraps around the clock as set by the `upperLimit` property. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public mutating func subtract(wrapping: Components) { + let newTC = __subtract(wrapping: wrapping, from: components) + + _ = setTimecode(exactly: newTC) + } + + /// Subtract a duration from the current timecode and return a new instance with the new timecode. + /// Returns nil if resulting value is not within valid timecode range. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func subtracting(_ exactly: Components) -> OTTimecode? { + guard let newTC = __subtract(exactly: exactly, from: components) else { return nil } + + var newOTTimecode = self // copy self + guard newOTTimecode.setTimecode(exactly: newTC) else { return nil } + + return newOTTimecode + } + + /// Subtract a duration from the current timecode and return a new instance with the new timecode. + /// Clamps to valid timecodes. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func subtracting(clamping values: Components) -> OTTimecode { + let newTC = __subtract(clamping: values, from: components) + + var newOTTimecode = self // copy self + _ = newOTTimecode.setTimecode(exactly: newTC) // guaranteed to work + + return newOTTimecode + } + + /// Subtract a duration from the current timecode and return a new instance with the new timecode. + /// Wraps around the clock as set by the `upperLimit` property. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func subtracting(wrapping values: Components) -> OTTimecode { + let newTC = __subtract(wrapping: values, from: components) + + var newOTTimecode = self // copy self + _ = newOTTimecode.setTimecode(exactly: newTC) // guaranteed to work + + return newOTTimecode + } + + + // MARK: - Multiply + + /// Multiply the current timecode by an amount. + /// Returns false if resulting value is > the `upperLimit` property. + public mutating func multiply(_ exactly: Double) -> Bool { + guard let newTC = __multiply(exactly: exactly, with: components) else { return false } + + return setTimecode(exactly: newTC) + } + + /// Multiply the current timecode by an amount. + /// Clamps the result to the `upperLimit` property. + public mutating func multiply(clamping value: Double) { + let newTC = __multiply(clamping: value, with: components) + + _ = setTimecode(exactly: newTC) // guaranteed to work + } + + /// Multiply the current timecode by an amount. + /// Wraps around the clock as set by the `upperLimit` property. + public mutating func multiply(wrapping value: Double) { + let newTC = __multiply(wrapping: value, with: components) + + _ = setTimecode(exactly: newTC) // guaranteed to work + } + + /// Multiply a duration from the current timecode and return a new instance with the new timecode. + /// Returns nil if resulting value is not within valid timecode range. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func multiplying(_ exactly: Double) -> OTTimecode? { + guard let newTC = __multiply(exactly: exactly, with: components) else { return nil } + + var newOTTimecode = self // copy self + guard newOTTimecode.setTimecode(exactly: newTC) else { return nil } + + return newOTTimecode + } + + /// Multiply a duration from the current timecode and return a new instance with the new timecode. + /// Clamps to valid timecodes. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func multiplying(clamping value: Double) -> OTTimecode { + let newTC = __multiply(clamping: value, with: components) + + var newOTTimecode = self // copy self + _ = newOTTimecode.setTimecode(exactly: newTC) // guaranteed to work + + return newOTTimecode + } + + /// Multiply a duration from the current timecode and return a new instance with the new timecode. + /// Wraps around the clock as set by the `upperLimit` property. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func multiplying(wrapping value: Double) -> OTTimecode { + let newTC = __multiply(wrapping: value, with: components) + + var newOTTimecode = self // copy self + _ = newOTTimecode.setTimecode(exactly: newTC) // guaranteed to work + + return newOTTimecode + } + + + // MARK: - Divide + + /// Divide the current timecode by a duration. + /// Returns false if resulting value is > the `upperLimit` property. + public mutating func divide(_ exactly: Double) -> Bool { + guard let newTC = __divide(exactly: exactly, into: components) else { return false } + + return setTimecode(exactly: newTC) + } + + /// Divide the current timecode by a duration. + /// Clamps to valid timecodes. + public mutating func divide(clamping value: Double) { + let newTC = __divide(clamping: value, into: components) + + _ = setTimecode(exactly: newTC) // guaranteed to work + } + + /// Divide the current timecode by a duration. + /// Wraps around the clock as set by the `upperLimit` property. + public mutating func divide(wrapping value: Double) { + let newTC = __divide(wrapping: value, into: components) + + _ = setTimecode(exactly: newTC) // guaranteed to work + } + + /// Divide the current timecode by a duration and return a new instance with the new timecode. + /// Returns nil if resulting value is not within valid timecode range. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func dividing(_ exactly: Double) -> OTTimecode? { + guard let newTC = __divide(exactly: exactly, into: components) else { return nil } + + var newOTTimecode = self // copy self + guard newOTTimecode.setTimecode(exactly: newTC) else { return nil } + + return newOTTimecode + } + + /// Divide the current timecode by a duration and return a new instance with the new timecode. + /// Clamps to valid timecodes. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func dividing(clamping value: Double) -> OTTimecode { + let newTC = __divide(clamping: value, into: components) + + var newOTTimecode = self // copy self + _ = newOTTimecode.setTimecode(exactly: newTC) // guaranteed to work + + return newOTTimecode + } + + /// Divide the current timecode by a duration and return a new instance with the new timecode. + /// Wraps around the clock as set by the `upperLimit` property. + /// Input values can be as large as desired and will be calculated recursively. ie: (0,0,0,1000) or (0,0,500,0) + public func dividing(wrapping value: Double) -> OTTimecode { + let newTC = __divide(wrapping: value, into: components) + + var newOTTimecode = self // copy self + _ = newOTTimecode.setTimecode(exactly: newTC) // guaranteed to work + + return newOTTimecode + } + +} diff --git a/Sources/SwiftTimecode/Math/OTTimecode Operators.swift b/Sources/SwiftTimecode/Math/OTTimecode Operators.swift new file mode 100644 index 00000000..c327dc69 --- /dev/null +++ b/Sources/SwiftTimecode/Math/OTTimecode Operators.swift @@ -0,0 +1,54 @@ +// +// OTTimecode Operators.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +// MARK: - Math operators: Self, Self + +extension OTTimecode { + + static public func +(lhs: Self, rhs: Self) -> OTTimecode { + return lhs.adding(clamping: rhs.components) + } + + static public func +=(lhs: inout Self, rhs: Self) { + lhs.add(clamping: rhs.components) + } + + static public func -(lhs: Self, rhs: Self) -> OTTimecode { + return lhs.subtracting(clamping: rhs.components) + } + + static public func -=(lhs: inout Self, rhs: Self) { + lhs.subtract(clamping: rhs.components) + } + +} + + +// MARK: - Math operators: Self, BinaryInteger + +extension OTTimecode { + + static public func *(lhs: Self, rhs: T) -> Self { + return lhs.multiplying(clamping: Double(rhs)) + } + + static public func *=(lhs: inout Self, rhs: T) { + lhs.multiply(clamping: Double(rhs)) + } + + static public func /(lhs: Self, rhs: T) -> Self { + return lhs.dividing(clamping: Double(rhs)) + } + + static public func /=(lhs: inout Self, rhs: T) { + lhs.divide(clamping: Double(rhs)) + } + +} diff --git a/Sources/SwiftTimecode/OTTime/OTTime.swift b/Sources/SwiftTimecode/OTTime/OTTime.swift new file mode 100644 index 00000000..ffc96d59 --- /dev/null +++ b/Sources/SwiftTimecode/OTTime/OTTime.swift @@ -0,0 +1,85 @@ +// +// OTTime.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +/// Primitive struct to represent real time. +/// +/// In effort to retain precision, the value used when initializing will be stored unchanged, but can be accessed by either accessor. The backing value can be read as either `.seconds` or `.ms`. If the original format's property is get, the backing value will be returned unchanged. Otherwise it will be converted when needed when the properties are accessed. +public struct OTTime { + + // MARK: Data backing + + private let msValue: Double? + + private let secondsValue: Double? + + // MARK: Public properties + + public let backing: UnitBacking + + public var ms: Double { + if msValue != nil { return msValue! } + if secondsValue != nil { return secondsValue! * 1000.0 } + return 0.0 + } + + public var seconds: Double { + if secondsValue != nil { return secondsValue! } + if msValue != nil { return msValue! / 1000.0 } + return 0.0 + } + + // MARK: init + + public init(ms: Double) { + backing = .ms + + msValue = ms + secondsValue = nil + } + + public init(seconds: Double) { + backing = .seconds + + msValue = nil + secondsValue = seconds + } + +} + +extension OTTime: Equatable { + + public static func ==(lhs: Self, rhs: Self) -> Bool { + // limit precision to help ensure comparison is meaningful + + return lhs.ms.truncated(decimalPlaces: 9) == rhs.ms.truncated(decimalPlaces: 9) + } + +} + + +extension OTTime: Comparable { + + public static func <(lhs: Self, rhs: Self) -> Bool { + // limit precision to help ensure comparison is meaningful + + lhs.ms.truncated(decimalPlaces: 9) < rhs.ms.truncated(decimalPlaces: 9) + } + +} + +extension OTTime { + + /// Enum describing units of time, as stored by `OTTime` + public enum UnitBacking { + case ms + case seconds + } + +} diff --git a/Sources/SwiftTimecode/OTTimecode Elapsed Frames.swift b/Sources/SwiftTimecode/OTTimecode Elapsed Frames.swift new file mode 100644 index 00000000..809dbbd5 --- /dev/null +++ b/Sources/SwiftTimecode/OTTimecode Elapsed Frames.swift @@ -0,0 +1,188 @@ +// +// OTTimecode Elapsed Frames.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + /// Returns the total number of whole frames elapsed from zero up to the timecode values. + /// When set, timecode is updated as long as the value passed is in valid range. + /// (Validation is based on the frame rate and `upperLimit` property.) + public var totalElapsedFrames: Double { + + get { + return Self.totalElapsedFrames(of: components, + at: frameRate, + subFramesDivisor: subFramesDivisor) + } + set { + guard newValue >= 0.0 + && newValue <= maxFramesAndSubframesExpressible + else { return } + + let converted = Self.components(from: newValue, + at: frameRate, + subFramesDivisor: subFramesDivisor) + + days = converted.d + hours = converted.h + minutes = converted.m + seconds = converted.s + frames = converted.f + subFrames = converted.sf + } + + } + +} + + +// MARK: - Static methods + +extension OTTimecode { + + /// Internal func: calculates total frames from given values at the current frame rate. + /// (To keep values clean, instead of returning a Double including subframes, this returns an Int with total frames, truncating the subframes. You can then add subframes to this return value to get a floating-point number representing total frames to the subframe precision in a single value.) + public static func totalElapsedFrames(of values: Components, + at frameRate: FrameRate, + subFramesDivisor: Int? = nil) -> Double + { + + let subFramesUnitInterval = + subFramesDivisor == nil + ? 0.0 + : Double(values.sf) / Double(subFramesDivisor!) + + switch frameRate.isDrop { + case true: + let totalMinutes = (24 * 60 * values.d) + (60 * values.h) + values.m + + let totalWholeFrames = + ( + (frameRate.maxFrames * 60 * 60 * 24 * values.d) + + (frameRate.maxFrames * 60 * 60 * values.h) + + (frameRate.maxFrames * 60 * values.m) + + (frameRate.maxFrames * values.s) + + (values.f) + ) + + - (Int(frameRate.framesDroppedPerMinute) * (totalMinutes - (totalMinutes / 10))) + + let totalFramesPlusSubframes = Double(totalWholeFrames) + subFramesUnitInterval + + return totalFramesPlusSubframes + + case false: + let dd = Double(values.d) * 24 * 60 * 60 * frameRate.frameRateForElapsedFramesCalculation + let hh = Double(values.h) * 60 * 60 * frameRate.frameRateForElapsedFramesCalculation + let mm = Double(values.m) * 60 * frameRate.frameRateForElapsedFramesCalculation + let ss = Double(values.s) * frameRate.frameRateForElapsedFramesCalculation + let totalWholeFrames = Int(round(dd + hh + mm + ss)) + values.f + + let totalFramesPlusSubframes = Double(totalWholeFrames) + subFramesUnitInterval + + return totalFramesPlusSubframes + + } + + } + + /// Internal func: calculates resulting values from total frames at the current frame rate. + /// (You can add subframes afterward to the `sf` property if needed.) + public static func components(from totalElapsedFrames: Int, + at frameRate: FrameRate, + subFramesDivisor: Int?) -> Components + { + + return components(from: Double(totalElapsedFrames), + at: frameRate, + subFramesDivisor: subFramesDivisor) + + } + + /// Internal func: calculates resulting values from total frames at the current frame rate. + /// (You can add subframes afterward to the `sf` property if needed.) + public static func components(from totalElapsedFrames: Double, + at frameRate: FrameRate, + subFramesDivisor: Int?) -> Components + { + + // prep vars + + var dd = 00 + var hh = 00 + var mm = 00 + var ss = 00 + var ff = 00 + var sf = 00 + + var inElapsedFrames = Double(totalElapsedFrames) + + // drop frame + + if frameRate.isDrop { + + // modify input elapsed frame count in the case of a drop-frame frame rate so it can be converted + + let framesPer10Minutes = frameRate.frameRateForElapsedFramesCalculation * 600 + + let D = floor(inElapsedFrames / framesPer10Minutes) + + let M = inElapsedFrames.truncatingRemainder(dividingBy: framesPer10Minutes) + + let F = max(0, M - frameRate.framesDroppedPerMinute) // don't allow negative numbers + + inElapsedFrames = + inElapsedFrames + + (9 * frameRate.framesDroppedPerMinute * D) + + (frameRate.framesDroppedPerMinute + * floor(F / ((framesPer10Minutes - frameRate.framesDroppedPerMinute) / 10)) ) + + } + + // final calculation + + let frMaxFrames = Double(frameRate.maxFrames) + + dd = Int( + (inElapsedFrames / (frMaxFrames * 60 * 60 * 24)) + .floor + ) + + hh = Int( + (inElapsedFrames / (frMaxFrames * 60 * 60)) + .floor + .truncatingRemainder(dividingBy: 24) + ) + + mm = Int( + (inElapsedFrames / (frMaxFrames * 60)) + .floor + .truncatingRemainder(dividingBy: 60) + ) + + ss = Int( + (inElapsedFrames / frMaxFrames) + .floor + .truncatingRemainder(dividingBy: 60) + ) + + ff = Int( + inElapsedFrames + .truncatingRemainder(dividingBy: frMaxFrames) + ) + + if let sfDiv = subFramesDivisor { + sf = Int(inElapsedFrames.fraction * Double(sfDiv)) + } + + return Components(d: dd, h: hh, m: mm, s: ss, f: ff, sf: sf) + + } + +} diff --git a/Sources/SwiftTimecode/OTTimecode String Extensions.swift b/Sources/SwiftTimecode/OTTimecode String Extensions.swift new file mode 100644 index 00000000..f10ad6fa --- /dev/null +++ b/Sources/SwiftTimecode/OTTimecode String Extensions.swift @@ -0,0 +1,63 @@ +// +// OTTimecode String Extensions.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +// MARK: - to OTTimecode + +extension String { + + /// Returns an instance of `OTTimecode(exactly:)`. + /// If the string is not a valid timecode string, it returns nil. + public func toTimecode(at frameRate: OTTimecode.FrameRate, + limit: OTTimecode.UpperLimit = ._24hours, + subFramesDivisor: Int? = nil) -> OTTimecode? + { + + if let sfd = subFramesDivisor { + + return OTTimecode(self, + at: frameRate, + limit: limit, + subFramesDivisor: sfd) + + } else { + + return OTTimecode(self, + at: frameRate, + limit: limit) + + } + + } + + /// Returns an instance of `OTTimecode(rawValues:)`. + /// If the string is not a valid timecode string, it returns nil. + public func toTimecode(rawValuesAt frameRate: OTTimecode.FrameRate, + limit: OTTimecode.UpperLimit = ._24hours, + subFramesDivisor: Int? = nil) -> OTTimecode? + { + + if let sfd = subFramesDivisor { + + return OTTimecode(rawValues: self, + at: frameRate, + limit: limit, + subFramesDivisor: sfd) + + } else { + + return OTTimecode(rawValues: self, + at: frameRate, + limit: limit) + + } + + } + +} diff --git a/Sources/SwiftTimecode/OTTimecode Validation.swift b/Sources/SwiftTimecode/OTTimecode Validation.swift new file mode 100644 index 00000000..469443ee --- /dev/null +++ b/Sources/SwiftTimecode/OTTimecode Validation.swift @@ -0,0 +1,235 @@ +// +// OTTimecode Validation.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + /// Returns a set of invalid components, if any. + /// A fully valid timecode will return an empty set. + /// Validation relies on `frameRate` and `upperLimit`. + public var invalidComponents: Set + { + + Self.invalidComponents(in: self.components, + at: frameRate, + limit: upperLimit, + subFramesDivisor: subFramesDivisor) + + } + +} + +extension OTTimecode.Components { + + /// Returns a set of invalid components, if any. + /// A fully valid timecode will return an empty set. + public func invalidComponents(at frameRate: OTTimecode.FrameRate, + limit: OTTimecode.UpperLimit, + subFramesDivisor: Int) -> Set + { + + OTTimecode.invalidComponents(in: self, + at: frameRate, + limit: limit, + subFramesDivisor: subFramesDivisor) + + } + +} + +extension OTTimecode { + + /// Returns a set of invalid components, if any. + /// A fully valid timecode will return an empty set. + public static func invalidComponents(in components: TCC, + at frameRate: FrameRate, + limit: UpperLimit, + subFramesDivisor: Int) -> Set + { + + var invalids: Set = [] + + // days + + if !components.validRange(of: .days, + at: frameRate, + limit: limit, + subFramesDivisor: subFramesDivisor) + .contains(components.d) + { invalids.insert(.days) } + + // hours + + if !components.validRange(of: .hours, + at: frameRate, + limit: limit, + subFramesDivisor: subFramesDivisor) + .contains(components.h) + { invalids.insert(.hours) } + + // minutes + + if !components.validRange(of: .minutes, + at: frameRate, + limit: limit, + subFramesDivisor: subFramesDivisor) + .contains(components.m) + { invalids.insert(.minutes) } + + // seconds + + if !components.validRange(of: .seconds, + at: frameRate, + limit: limit, + subFramesDivisor: subFramesDivisor) + .contains(components.s) + { invalids.insert(.seconds) } + + // frames + if !components.validRange(of: .frames, + at: frameRate, + limit: limit, + subFramesDivisor: subFramesDivisor) + .contains(components.f) + { invalids.insert(.frames) } + + // subframes + if !components.validRange(of: .subFrames, + at: frameRate, + limit: limit, + subFramesDivisor: subFramesDivisor) + .contains(components.sf) + { invalids.insert(.subFrames) } + + return invalids + + } + +} + +extension OTTimecode { + + /// Returns valid range of values for a timecdoe component, given the current `frameRate` and `upperLimit`. + public func validRange(of component: Component) -> (ClosedRange) + { + + components.validRange(of: component, + at: frameRate, + limit: upperLimit, + subFramesDivisor: subFramesDivisor) + + } + +} + +extension OTTimecode.Components { + + /// Returns valid range of values for a timecdoe component. + public func validRange(of component: OTTimecode.Component, + at frameRate: OTTimecode.FrameRate, + limit: OTTimecode.UpperLimit, + subFramesDivisor: Int) -> (ClosedRange) + { + + switch component { + + case .days: + return 0...limit.maxDaysExpressible + + case .hours: + return 0...23 + + case .minutes: + return 0...59 + + case .seconds: + return 0...59 + + case .frames: + let startFramePossible = frameRate.isDrop + ? ((m % 10 != 0 && s == 0) ? 2 : 0) + : 0 + + return startFramePossible...frameRate.maxFrameNumberDisplayable + + case .subFrames: + // clamp divisor to prevent a possible crash if subFramesDivisor < 0 + return 0...(subFramesDivisor.clamped(to: 1...) - 1) + + } + + } + +} + +extension OTTimecode { + + internal mutating func __clamp(component: Component) { + + switch component { + case .days: + days = days.clamped(to: validRange(of: .days)) + + case .hours: + hours = hours.clamped(to: validRange(of: .hours)) + + case .minutes: + minutes = minutes.clamped(to: validRange(of: .minutes)) + + case .seconds: + seconds = seconds.clamped(to: validRange(of: .seconds)) + + case .frames: + frames = frames.clamped(to: validRange(of: .frames)) + + case .subFrames: + subFrames = subFrames.clamped(to: validRange(of: .subFrames)) + + } + + } + +} + +extension OTTimecode { + + /// Validates and clamps all timecode components to valid values at the current `frameRate` and `upperLimit` bound. + public mutating func clampComponents() { + + __clamp(component: .days) + __clamp(component: .hours) + __clamp(component: .minutes) + __clamp(component: .seconds) + __clamp(component: .frames) + __clamp(component: .subFrames) + + } + +} + +extension OTTimecode { + + /// Returns the largest subframe value displayable before rolling over to the next frame. + public var maxSubFramesExpressible: Int { + + validRange(of: .subFrames) + .upperBound + + } + + /// Returns the `upperLimit` minus 1 subframe. + public var maxFramesAndSubframesExpressible: Double { + + Double(frameRate.maxTotalFramesExpressible(in: upperLimit)) + + Double(maxSubFramesExpressible) + / Double(subFramesDivisor) + + } + +} diff --git a/Sources/SwiftTimecode/OTTimecode init.swift b/Sources/SwiftTimecode/OTTimecode init.swift new file mode 100644 index 00000000..4496cf68 --- /dev/null +++ b/Sources/SwiftTimecode/OTTimecode init.swift @@ -0,0 +1,208 @@ +// +// OTTimecode init.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + // MARK: Basic + + /// Instance with default values. + public init(at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + } + + + // MARK: Components + + /** Instance with timecode values and frame rate. Optionally set `upperLimit`. + Values which are out-of-bounds will return nil. + (Validation is based on the frame rate and `upperLimit` property.) + */ + public init?(_ exactly: Components, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + if !setTimecode(exactly: exactly) { return nil } + + } + + /** Instance with timecode values and frame rate. Optionally set `upperLimit`. + Values which are out-of-bounds will be clamped to minimum or maximum possible values. + (Clamping is based on the frame rate and `upperLimit` property.) + */ + public init(clamping: Components, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + setTimecode(clamping: clamping) + + } + + /** Instance with timecode values and frame rate. Optionally set `upperLimit`. + Timecodes will be wrapped around the timecode clock if out-of-bounds. + (Wrapping is based on the frame rate and `upperLimit` property.) + */ + public init(wrapping: Components, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + setTimecode(wrapping: wrapping) + + } + + /** Instance with timecode values and frame rate. + Timecode values will not be validated or rejected if they overflow. + */ + public init(rawValues: Components, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + setTimecode(rawValues: rawValues) + + } + + + // MARK: String + + /** Instance with timecode string and frame rate. Optionally set `upperLimit`. + An improperly formatted timecode string or one with invalid values will return nil. + (Validation is based on the frame rate and `upperLimit` property.) + */ + public init?(_ exactly: String, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + if !setTimecode(exactly: exactly) { return nil } + + } + + /** Instance with timecode string and frame rate. Optionally set `upperLimit`. + Values which are out-of-bounds will be clamped to minimum or maximum possible values. + (Clamping is based on the frame rate and `upperLimit` property.) + */ + public init?(clamping: String, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + if !setTimecode(clamping: clamping) { return nil } // validation is triggered by this method + + } + + /** Instance with timecode string and frame rate. Optionally set `upperLimit`. + An improperly formatted timecode string or one with invalid values will return nil. + (Wrapping is based on the frame rate and `upperLimit` property.) + */ + public init?(wrapping: String, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + if !setTimecode(wrapping: wrapping) { return nil } + + } + + /** Instance with timecode values and frame rate. + Timecode values will not be validated or rejected if they overflow. + */ + public init?(rawValues: String, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + if !setTimecode(rawValues: rawValues) { return nil } + + } + + // MARK: Real time + + /// Instance with real time and frame rate. Optionally set `upperLimit`. + public init?(_ lossy: OTTime, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + if !self.setTimecode(from: lossy) { return nil } + + } + + /// Instance with real time and frame rate. Optionally set `upperLimit`. + public init?(samples: Double, + sampleRate: Int, + at frameRate: FrameRate, + limit: UpperLimit = ._24hours, + subFramesDivisor: Int = 80) + { + + self.frameRate = frameRate + self.upperLimit = limit + self.subFramesDivisor = subFramesDivisor + + if !self.setTimecode(fromSamplesValue: samples, atSampleRate: sampleRate) { return nil } + + } + +} diff --git a/Sources/SwiftTimecode/OTTimecode.swift b/Sources/SwiftTimecode/OTTimecode.swift new file mode 100644 index 00000000..9d1bb470 --- /dev/null +++ b/Sources/SwiftTimecode/OTTimecode.swift @@ -0,0 +1,90 @@ +// +// OTTimecode.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2018-07-08. +// Copyright © 2018 Steffan Andrews. All rights reserved. +// + +import Foundation + + +/// Object representing SMPTE timecode data with a variety of number- and string- based constructors, including helper functions to convert between them operators to perform math operations between them. +public struct OTTimecode { + + // MARK: - Immutable properties + + /// Frame rate. + /// + /// Note: Several properties are available on the frame rate that is selected, including its `.stringValue` representation or whether the rate `.isDrop`. + /// + /// Setting this value directly does not trigger any validation. + public let frameRate: FrameRate + + /// Timecode maximum upper bound. + /// + /// This also affects how timecode values wrap when adding or clamping. + /// + /// Setting this value directly does not trigger any validation. + public let upperLimit: UpperLimit + + /// Subframes divisor. + /// + /// The number of subframes that make up a single frame. + /// + /// (ie: a divisor of 80 implies a range of 0...79) + /// + /// This will vary depending on application. Most common divisors are 80 or 100. + public let subFramesDivisor: Int + + /// Determines whether subframes are included when getting `.stringValue`. + /// + /// This does not disable subframes from being stored or calculated, only whether they are output in the string. + public var displaySubFrames: Bool = false + + // MARK: - Mutable properties + + /// Timecode days. + /// + /// Valid only if `.upperLimit` is set to `._100days`. + /// + /// Setting this value directly does not trigger any validation. + public var days: Int = 0 + + /// Timecode hours. + /// + /// Valid range: 0-23. + /// + /// Setting this value directly does not trigger any validation. + public var hours: Int = 0 + + /// Timecode minutes. + /// + /// Valid range: 0-59. + /// + /// Setting this value directly does not trigger any validation. + public var minutes: Int = 0 + + /// Timecode seconds. + /// + /// Valid range: 0-59. + /// + /// Setting this value directly does not trigger any validation. + public var seconds: Int = 0 + + /// Timecode frames. + /// + /// Valid range is dependent on the `frameRate` property (0-23 for 24NDF, 0-29 for 30NDF, 2-29 every minute except 0-29 for every 10th minute for 29.97DF, etc.). + /// + /// Setting this value directly does not trigger any validation. + public var frames: Int = 0 + + /// Timecode subframe component. + /// + /// To remain platform agnostic, this value is represented as a floating-point unit interval (0.0...1.0) since there are no consistent standards on subframe divisions. + /// (ie: traditionally Cubase/Nuendo and Logic Pro use 80 subframes, Pro Tools uses 100 subframes, etc.) + /// + /// Setting this value directly does not trigger any validation. + public var subFrames: Int = 0 + +} diff --git a/Sources/SwiftTimecode/Protocol Adoptions/Comparable.swift b/Sources/SwiftTimecode/Protocol Adoptions/Comparable.swift new file mode 100644 index 00000000..d1bd2150 --- /dev/null +++ b/Sources/SwiftTimecode/Protocol Adoptions/Comparable.swift @@ -0,0 +1,25 @@ +// +// Comparable.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode: Equatable { + + static public func ==(lhs: Self, rhs: Self) -> Bool { + return lhs.realTime == rhs.realTime + } + +} + +extension OTTimecode: Comparable { + + static public func <(lhs: Self, rhs: Self) -> Bool { + return (lhs.realTime < rhs.realTime) + } + +} diff --git a/Sources/SwiftTimecode/Protocol Adoptions/CustomStringConvertible.swift b/Sources/SwiftTimecode/Protocol Adoptions/CustomStringConvertible.swift new file mode 100644 index 00000000..aada647b --- /dev/null +++ b/Sources/SwiftTimecode/Protocol Adoptions/CustomStringConvertible.swift @@ -0,0 +1,35 @@ +// +// CustomStringConvertible.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-08-17. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode: CustomStringConvertible, CustomDebugStringConvertible { + + public var description: String { + + return stringValue + + } + + public var debugDescription: String { + // include Days even if it's 0 if we have a mode set that enables Days + let daysString = + upperLimit == UpperLimit._100days + ? "\(days):" + : "" + + return "OTTimecode<\(daysString)\(stringValue) @ \(frameRate.stringValue)>" + } + + public var verboseDescription: String { + + return "\(stringValue) @ \(frameRate.stringValue)" + + } + +} diff --git a/Sources/SwiftTimecode/Protocol Adoptions/Hashable.swift b/Sources/SwiftTimecode/Protocol Adoptions/Hashable.swift new file mode 100644 index 00000000..efa7e659 --- /dev/null +++ b/Sources/SwiftTimecode/Protocol Adoptions/Hashable.swift @@ -0,0 +1,27 @@ +// +// Hashable.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode: Hashable { + + // This relies on "enum FrameRate: Int" + + // To make the struct truly hashed, any crucial differentiating attributes must be encoded... + // Max frames of 100 days @ 120fps == + // 0b11 1101 1100 1100 0101 0000 0000 0000 -- 30 bits + + public func hash(into hasher: inout Hasher) { + // Add the framerate information in bits above the total frames value; 30 places to the left so they don't overlap + + hasher.combine(totalElapsedFrames) + hasher.combine(frameRate.rawValue) + + } + +} diff --git a/Sources/SwiftTimecode/Protocol Adoptions/Strideable.swift b/Sources/SwiftTimecode/Protocol Adoptions/Strideable.swift new file mode 100644 index 00000000..f8c30546 --- /dev/null +++ b/Sources/SwiftTimecode/Protocol Adoptions/Strideable.swift @@ -0,0 +1,32 @@ +// +// Strideable.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2018-07-20. +// Copyright © 2018 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode: Strideable { + + public typealias Stride = Int + + /// Returns a new instance advanced by specified time components. + /// Same as calling `.adding(clamping: TCC(f: n))` but implemented in order to allow OTTimecode to conform to `Strideable`. + /// Will clamp to valid timecode range. + public func advanced(by n: Int) -> Self { + + return self.adding(clamping: Components(f: n)) + + } + + /// Distance between two timecodes expressed as number of frames. + /// Implemented in order to allow OTTimecode to conform to `Strideable`. + public func distance(to other: Self) -> Int { + + return Int(trunc(other.totalElapsedFrames) - trunc(self.totalElapsedFrames)) + + } + +} diff --git a/Sources/SwiftTimecode/UI/TextField.swift b/Sources/SwiftTimecode/UI/TextField.swift new file mode 100644 index 00000000..8992d15f --- /dev/null +++ b/Sources/SwiftTimecode/UI/TextField.swift @@ -0,0 +1,65 @@ +// +// TextField.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-07-11. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +#if os(macOS) + +import Cocoa + +extension OTTimecode { + + /// NSTextField subclass with timecode formatting + /// + /// Formatter is in effect bypassed until all its properties are set (frameRate, upperLimit, displaySubFrames, subFramesDivisor). These can be set after the class has initialized. + /// + /// See `.formatter` property to access these. + @objc(OTTimecodeTextField) + public class TextField: NSTextField { + + public required init?(coder: NSCoder) { + super.init(coder: coder) + + self.formatter = TextFormatter() + + self.allowsEditingTextAttributes = false + self.cell?.allowsEditingTextAttributes = false + } + + // responder chain: triggered when user presses Esc key + public override func cancelOperation(_ sender: Any?) { + //super.cancelOperation(sender) + + // cancel changes to text + abortEditing() + + // give focus back to self + window?.makeFirstResponder(self) + } + + } + +} + +extension OTTimecode { + + /// NSTextFieldCell subclass with timecode formatting + @objc(OTTimecodeTextFieldCell) + public class TextFieldCell: NSTextFieldCell { + + public required init(coder: NSCoder) { + super.init(coder: coder) + + formatter = TextFormatter() + + self.allowsEditingTextAttributes = false + } + + } + +} + +#endif diff --git a/Sources/SwiftTimecode/UpperLimit/UpperLimit.swift b/Sources/SwiftTimecode/UpperLimit/UpperLimit.swift new file mode 100644 index 00000000..0a76564a --- /dev/null +++ b/Sources/SwiftTimecode/UpperLimit/UpperLimit.swift @@ -0,0 +1,64 @@ +// +// Extent.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-06-15. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import Foundation + +extension OTTimecode { + + /// Enum describing the maximum timecode ceiling. + public enum UpperLimit: Int { + + /// Pro Tools' upper limit is "23:59:59:FF" which is 1 day (24 hours) in duration. + case _24hours + + /// Cubase's upper limit is "99 23:59:59:FF" which is 100 days in duration. + case _100days + + /// Internal use. + internal var maxDays: Int { + switch self { + case ._24hours: return 1 + case ._100days: return 100 + } + } + + /// Internal use. + internal var maxDaysExpressible: Int { + switch self { + case ._24hours: return maxDays - 1 + case ._100days: return maxDays - 1 + } + } + + /// Internal use. + internal var maxHours: Int { + switch self { + case ._24hours: return 24 + case ._100days: return 24 + } + } + + /// Internal use. + internal var maxHoursExpressible: Int { + switch self { + case ._24hours: return maxHours - 1 + case ._100days: return maxHours - 1 + } + } + + /// Internal use. + internal var maxHoursTotal: Int { + switch self { + case ._24hours: return maxDays - 1 + case ._100days: return (24 * maxDays) - 1 + } + } + + } + +} diff --git a/Sources/SwiftTimecode/Utilities/BinaryInteger.swift b/Sources/SwiftTimecode/Utilities/BinaryInteger.swift new file mode 100644 index 00000000..62cae31b --- /dev/null +++ b/Sources/SwiftTimecode/Utilities/BinaryInteger.swift @@ -0,0 +1,30 @@ +// +// File.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-09-17. +// + +import Foundation + +extension BinaryInteger { + + /// CUSTOM SHARED: + /// Returns number of digits (places to the left of the decimal) in the number. + /// + /// ie: + /// - for the integer 0, this would return 1 + /// - for the integer 5, this would return 1 + /// - for the integer 10, this would return 2 + /// - for the integer 250, this would return 3 + internal var numberOfDigits: Int { + + if self < 10 && self >= 0 || self > -10 && self < 0 { + return 1 + } else { + return 1 + (self / 10).numberOfDigits + } + + } + +} diff --git a/Sources/SwiftTimecode/Utilities/CharacterSet.swift b/Sources/SwiftTimecode/Utilities/CharacterSet.swift new file mode 100644 index 00000000..4c50c58e --- /dev/null +++ b/Sources/SwiftTimecode/Utilities/CharacterSet.swift @@ -0,0 +1,22 @@ +// +// File.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-09-17. +// + +import Foundation + +extension CharacterSet { + + /// CUSTOM SHARED: + /// Returns true if the CharacterSet contains the given Character. + internal func contains(_ character: Character) -> Bool { + + return character + .unicodeScalars + .allSatisfy(contains(_:)) + + } + +} diff --git a/Sources/SwiftTimecode/Utilities/Clamped.swift b/Sources/SwiftTimecode/Utilities/Clamped.swift new file mode 100644 index 00000000..d7667d43 --- /dev/null +++ b/Sources/SwiftTimecode/Utilities/Clamped.swift @@ -0,0 +1,70 @@ +// +// File.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-09-17. +// + +import Foundation + +// MARK: - .clamped(to:) + +extension Comparable { + + // ie: 5.clamped(to: 7...10) + // ie: 5.0.clamped(to: 7.0...10.0) + // ie: "a".clamped(to: "b"..."h") + /// CUSTOM SHARED: + /// Returns the value clamped to the passed range. + internal func clamped(to limits: ClosedRange) -> Self { + return min(max(self, limits.lowerBound), limits.upperBound) + } + + // ie: 5.clamped(to: 300...) + // ie: 5.0.clamped(to: 300.00...) + // ie: "a".clamped(to: "b"...) + /// CUSTOM SHARED: + /// Returns the value clamped to the passed range. + internal func clamped(to limits: PartialRangeFrom) -> Self { + return max(self, limits.lowerBound) + } + + // ie: 400.clamped(to: ...300) + // ie: 400.0.clamped(to: ...300.0) + // ie: "k".clamped(to: ..."h") + /// CUSTOM SHARED: + /// Returns the value clamped to the passed range. + internal func clamped(to limits: PartialRangeThrough) -> Self { + return min(self, limits.upperBound) + } + + // ie: 5.0.clamped(to: 7.0..<10.0) + // not a good idea to implement this -- floating point numbers don't make sense in a ..< type range + // because would the max of 7.0..<10.0 be 9.999999999...? It can't be 10.0. + // func clamped(to limits: Range) -> Self { } + +} + +extension Strideable { + + // ie: 400.clamped(to: ..<300) + // won't work for String + /// CUSTOM SHARED: + /// Returns the value clamped to the passed range. + internal func clamped(to limits: PartialRangeUpTo) -> Self { + return min(self, limits.upperBound.advanced(by: -1)) // advanced(by:) requires Strideable, not available on just Comparable + } + +} + +extension Strideable where Self.Stride: SignedInteger { + + // ie: 5.clamped(to: 7..<10) + // won't work for String + /// CUSTOM SHARED: + /// Returns the value clamped to the passed range. + internal func clamped(to limits: Range) -> Self { + return min(max(self, limits.lowerBound), limits.index(before: limits.upperBound)) // index(before:) only available on SignedInteger + } + +} diff --git a/Sources/SwiftTimecode/Utilities/File.swift b/Sources/SwiftTimecode/Utilities/File.swift new file mode 100644 index 00000000..f6b70052 --- /dev/null +++ b/Sources/SwiftTimecode/Utilities/File.swift @@ -0,0 +1,94 @@ +// +// File.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-09-17. +// + +import Foundation + +/// CUSTOM SHARED: +/// Outputs percentage values at defined intervals, from a input stream of values. +/// +/// `maxValue` must be greater than `minValue`. `progress(value: T)` will not produce output if `value` is less than `minValue`, however `value` > `maxValue` will continue producing outputs > 100%. +/// +/// Useful in debugging to the console to show incremental progress during operations that can take a fair amount of time to complete. +public struct SegmentedProgress { + + /// Minimum value. + public let minValue: T + /// Internal cached value + private let dblMinValue: Double + + /// Maximum value. + public let maxValue: T + /// Internal cached value + private let dblMaxValue: Double + + /// How many segments to divide the entire range into. + public var segments: Int = 100 { + didSet { calculateSegmentInterval() } + } + + /// Number of decimal places to round the percentage to. + public var roundedToPlaces: Int = 0 + + /// Internal, calculated. Do not access directly. + private var segmentInterval: Double = 1.0 + + /// Internal. Do not access directly. + private mutating func calculateSegmentInterval() { + if segments < 1 { segments = 1 } // validation + segmentInterval = (Double(maxValue) - Double(minValue)) / Double(segments) + } + + /// Internal + private(set) var lastSegmentValue: Double + /// Internal + private var firstSegmentIssued = false + /// Internal + private var lastSegmentIssued = false + + public mutating func progress(value: T) -> String? { + if (value == minValue) && !firstSegmentIssued { + firstSegmentIssued = true + return roundedPercentageString(0.0, toPlaces: roundedToPlaces) + } + + if (value == maxValue) && !lastSegmentIssued { + lastSegmentIssued = true + return roundedPercentageString(100.0, toPlaces: roundedToPlaces) + } + + let dblValue = Double(value) + + if dblValue >= lastSegmentValue + segmentInterval { + let percentage = ((dblValue - dblMinValue) / (dblMaxValue - dblMinValue)) * 100.0 + + lastSegmentValue = dblValue + return roundedPercentageString(percentage, toPlaces: roundedToPlaces) + } + + return nil + } + + public init(_ range: ClosedRange, segments: Int = 100, roundedToPlaces: Int = 0) { + self.minValue = range.lowerBound + self.dblMinValue = Double(self.minValue) + + self.maxValue = range.upperBound + self.dblMaxValue = Double(self.maxValue) + + self.segments = segments + self.lastSegmentValue = dblMinValue + calculateSegmentInterval() // must call this here since segmets didSet won't trigger from init + + self.roundedToPlaces = roundedToPlaces + } + + /// Internal: Utility function. + private func roundedPercentageString(_ number: Double, toPlaces: Int) -> String { + return String(format: "%.\(toPlaces)f", number) + "%" + } + +} diff --git a/Sources/SwiftTimecode/Utilities/FloatingPoint.swift b/Sources/SwiftTimecode/Utilities/FloatingPoint.swift new file mode 100644 index 00000000..e8c3a7c4 --- /dev/null +++ b/Sources/SwiftTimecode/Utilities/FloatingPoint.swift @@ -0,0 +1,151 @@ +// +// File.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-09-17. +// + +import Foundation + +// MARK: - ceiling / floor + +extension FloatingPoint { + + /// CUSTOM SHARED: Convenience method to call `ceil()` + internal var ceiling: Self { + Darwin.ceil(self) + } + + /// CUSTOM SHARED: Convenience method to call `floor()` + internal var floor: Self { + Darwin.floor(self) + } + +} + +// MARK: - FloatingPointPower + +/// CUSTOM SHARED: +/// Protocol allowing implementation of convenience methods for .power(_ exponent:) +internal protocol FloatingPointPower { + func power(_ exponent: Self) -> Self +} + + +// MARK: - .power() + +extension Double: FloatingPointPower { + /// CUSTOM SHARED: + /// Convenience method for pow() + internal func power(_ exponent: Double) -> Double { + pow(self, exponent) + } +} + +extension Float: FloatingPointPower { + /// CUSTOM SHARED: + /// Convenience method for pow() + internal func power(_ exponent: Float) -> Float { + powf(self, exponent) + } +} + +// Float80 seems to be deprecated as of the introduction of ARM64 + +//extension Float80: FloatingPointPower { +// /// CUSTOM SHARED: +// /// Convenience method for pow() +// public func power(_ exponent: Float80) -> Float80 { +// powl(self, exponent) +// } +//} + +extension Decimal { + /// CUSTOM SHARED: + /// Convenience method for pow() + internal func power(_ exponent: Int) -> Decimal { + pow(self, exponent) + } +} + + +// MARK: - .truncated() / .rounded + +extension FloatingPoint where Self : FloatingPointPower { + + /// CUSTOM SHARED: + /// Truncates decimal places to `decimalPlaces` number of decimal places. If `decimalPlaces` <= 0, trunc(self) is returned. + internal func truncated(decimalPlaces: Int) -> Self { + if decimalPlaces < 1 { + return trunc(self) + } + + let offset = Self(10).power(Self(decimalPlaces)) + return trunc(self * offset) / offset + } + + /// CUSTOM SHARED: + /// Replaces this value by truncating it to `decimalPlaces` number of decimal places. If `decimalPlaces` <= 0, trunc(self) is used. + internal mutating func formTruncated(decimalPlaces: Int) { + self = self.truncated(decimalPlaces: decimalPlaces) + } + + /// CUSTOM SHARED: + /// Rounds to `decimalPlaces` number of decimal places using rounding `rule`. If `decimalPlaces` <= 0, trunc(self) is returned. + internal func rounded(_ rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero, decimalPlaces: Int) -> Self { + if decimalPlaces < 1 { + return self.rounded(rule) + } + + let offset = Self(10).power(Self(decimalPlaces)) + + return (self * offset).rounded(rule) / offset + } + + /// CUSTOM SHARED: + /// Replaces this value by rounding it to `decimalPlaces` number of decimal places using rounding `rule`. If `decimalPlaces` <= 0, trunc(self) is used. + internal mutating func round(_ rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero, decimalPlaces: Int) { + self = self.rounded(rule, decimalPlaces: decimalPlaces) + } + +} + +extension FloatingPoint { + + /// CUSTOM SHARED: + /// Similar to `Int.quotientAndRemainder(dividingBy:)` from the standard Swift library. + /// + /// Note: Internally, employs `trunc()` and `.truncatingRemainder(dividingBy:)`. + internal func quotientAndRemainder(dividingBy: Self) -> (quotient: Self, remainder: Self) { + let calculation = (self / dividingBy) + let integral = trunc(calculation) + let fraction = self.truncatingRemainder(dividingBy: dividingBy) + return (quotient: integral, remainder: fraction) + } + + /// CUSTOM SHARED: + /// Returns both integral part and fractional part. + /// This methos is more computationally efficient than calling `.integral` and .`fraction` properties separately unless you only require one or the other. + /// + /// Note: this can result in a non-trivial loss of precision for the fractional part. + internal var integralAndFraction: (integral: Self, fraction: Self) { + let integral = trunc(self) + let fraction = self - integral + return (integral: integral, fraction: fraction) + } + + /// CUSTOM SHARED: + /// Returns the integral part (digits before the decimal point) + internal var integral: Self { + integralAndFraction.integral + } + + /// CUSTOM SHARED: + /// Returns the fractional part (digits after the decimal point) + /// + /// Note: this can result in a non-trivial loss of precision for the fractional part. + internal var fraction: Self { + integralAndFraction.fraction + } + +} diff --git a/Sources/SwiftTimecode/Utilities/RegEx.swift b/Sources/SwiftTimecode/Utilities/RegEx.swift new file mode 100644 index 00000000..bd64a1cb --- /dev/null +++ b/Sources/SwiftTimecode/Utilities/RegEx.swift @@ -0,0 +1,75 @@ +// +// File.swift +// SwiftTimecode +// +// Created by Steffan Andrews on 2020-09-17. +// + +import Foundation + +extension String { + + /// CUSTOM SHARED: + /// Returns an array of RegEx matches + internal func regexMatches(pattern: String) -> [String] { + do { + let regex = try NSRegularExpression(pattern: pattern) + let nsString = self as NSString + let results = regex.matches(in: self, range: NSMakeRange(0, nsString.length)) + return results.map { nsString.substring(with: $0.range)} + } catch { // catch let error as NSError { + //llog("regexMatches(...) Error: Invalid RegEx: \(error.localizedDescription)") + return [] + } + } + + /// CUSTOM SHARED: + /// Returns a string from a tokenized string of RegEx matches + internal func regexMatches(pattern: String, replacementTemplate: String) -> String? { + do { + let regex = try NSRegularExpression(pattern: pattern) + let nsString = self as NSString + regex.numberOfMatches(in: self, + options: .withTransparentBounds, + range: NSMakeRange(0, nsString.length)) + let replaced = regex.stringByReplacingMatches(in: self, + options: .withTransparentBounds, + range: NSMakeRange(0, nsString.length), + withTemplate: replacementTemplate) + + return replaced + } catch { // catch let error as NSError { + return nil + } + } + + /// CUSTOM SHARED: + /// Returns capture groups from regex matches. nil if an optional capture group is not matched. + internal func regexMatches(captureGroupsFromPattern: String) -> [String?] { + do { + let regex = try NSRegularExpression(pattern: captureGroupsFromPattern, options: []) + let nsString = self as NSString + let results = regex.matches(in: self, + options: .withTransparentBounds, + range: NSMakeRange(0, nsString.length)) + var matches: [String?] = [] + + for result in results { + for i in 1.. NSAttributedString { + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = alignment + + guard let copy = self.mutableCopy() as? NSMutableAttributedString + else { return self } + + copy.addAttributes([ .paragraphStyle : paragraph ], + range: NSRange(location: 0, length: self.length)) + + return copy + + } + +} + +extension NSMutableAttributedString { + + /// CUSTOM SHARED: + /// Convenience. Adds the attribute applied to the entire string. + internal func addAttribute(alignment: NSTextAlignment) { + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = alignment + + self.addAttributes([ .paragraphStyle : paragraph ], + range: NSRange(location: 0, length: self.length)) + + } + +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 00000000..3e7c3466 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,4 @@ +// LinuxMain implementation is no longer required as of Swift 5.1. +// see https://oleb.net/2020/swift-test-discovery/ + +fatalError("Run the tests with `swift test --enable-test-discovery`") diff --git a/Tests/SwiftTimecode-Dev-Tests/OTTimecode Elapsed Frames ExtendedTests.swift b/Tests/SwiftTimecode-Dev-Tests/OTTimecode Elapsed Frames ExtendedTests.swift new file mode 100644 index 00000000..ecb88a56 --- /dev/null +++ b/Tests/SwiftTimecode-Dev-Tests/OTTimecode Elapsed Frames ExtendedTests.swift @@ -0,0 +1,110 @@ +// +// OTTimecode Elapsed Frames ExtendedTests.swift +// OTTimecodeExtendedTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_ET_ExtendedTests: XCTestCase { + + func testOTTimecode_Iterative() { + + // return early to bypass this dev-only test + + XCTAssertTrue(true) + return + + // test conversions from components(from:) and totalElapsedFrames(of:) + + // ============================================================================== + // NOTE: + // ============================================================================== + // this is a brute-force test not meant to be run frequently, + // but as a diagnostic testbed only when major changes are made to the library + // to ensure that conversions are accurate + // ============================================================================== + + // ======= parameters ======= + + let limit: OTTimecode.UpperLimit = ._24hours +// let limit: OTTimecode.UpperLimit = ._100days + + let frameRatesToTest: [OTTimecode.FrameRate] = OTTimecode.FrameRate.allCases +// let frameRatesToTest: [OTTimecode.FrameRate] = OTTimecode.FrameRate.allDrop +// let frameRatesToTest: [OTTimecode.FrameRate] = OTTimecode.FrameRate.allNonDrop +// let frameRatesToTest: [OTTimecode.FrameRate] = [._60_drop, ._120_drop] + + // ======= run ============== + + for fr in frameRatesToTest { + + let tc = OTTimecode(at: fr, limit: limit) + + // log status + print ("Testing all frames in \(tc.upperLimit) at \(fr.stringValue)... ", terminator: "") + + var failures: [(Int, TCC)] = [] + + let ubound = tc.frameRate.maxTotalFrames(in: tc.upperLimit) + + var per = SegmentedProgress(0...ubound, segments: 20, roundedToPlaces: 0) + + for i in 0...ubound { + let vals = OTTimecode.components(from: i, + at: tc.frameRate, + subFramesDivisor: tc.subFramesDivisor) + + if i != Int(floor(OTTimecode.totalElapsedFrames(of: vals, at: tc.frameRate, + subFramesDivisor: tc.subFramesDivisor))) + + { failures.append((i, vals)) } + + // log status + if let percentageToPrint = per.progress(value: i) { + print("\(percentageToPrint) ", terminator: "") + } + } + print("") // finalize log with newline char + + XCTAssertEqual(failures.count, 0, "Failed iterative test for \(fr) with \(failures.count) failures.") + + if failures.count > 0 { + print("First", + fr, + "failure: input elapsed frames", + failures.first!.0, + "converted to components", + failures.first!.1, + "converted back to", + OTTimecode.totalElapsedFrames(of: failures.first!.1, + at: tc.frameRate, + subFramesDivisor: tc.subFramesDivisor), + "elapsed frames.") + + } + if failures.count > 1 { + print("Last", + fr, + "failure: input elapsed frames", + failures.last!.0, + "converted to components", + failures.last!.1, + "converted back to", + OTTimecode.totalElapsedFrames(of: failures.last!.1, + at: tc.frameRate, + subFramesDivisor: tc.subFramesDivisor), + "elapsed frames.") + + } + + } + + print("Done") + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Integration Tests/OTTimecode Integration Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Integration Tests/OTTimecode Integration Tests.swift new file mode 100644 index 00000000..cb808a1f --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Integration Tests/OTTimecode Integration Tests.swift @@ -0,0 +1,124 @@ +// +// SwiftTimecodeTests.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2019-09-30. +// Copyright © 2019 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_IT_IntegrationTests: XCTestCase { + + func testOTTimecode_Clamping() { + + // 24 hour + + OTTimecode.FrameRate.allCases.forEach { + + XCTAssertEqual(OTTimecode(clamping: TCC(h: -1, m: -1, s: -1, f: -1), at: $0).components, + TCC(d: 0, h: 0, m: 0, s: 0, f: 0), + "for \($0)") + + } + + OTTimecode.FrameRate.allCases.forEach { + let clamped = OTTimecode(clamping: TCC(h: 99, m: 99, s: 99, f: 10000), at: $0).components + + XCTAssertEqual(clamped, TCC(d: 0, h: 23, m: 59, s: 59, f: $0.maxFrameNumberDisplayable), "for \($0)") + } + + // 24 hour - testing with days + + OTTimecode.FrameRate.allCases.forEach { + + XCTAssertEqual(OTTimecode(clamping: TCC(d: -1, h: -1, m: -1, s: -1, f: -1), at: $0).components, + TCC(d: 0, h: 0, m: 0, s: 0, f: 0), + "for \($0)") + + } + + OTTimecode.FrameRate.allCases.forEach { + let clamped = OTTimecode(clamping: TCC(d: 99, h: 99, m: 99, s: 99, f: 10000), at: $0).components + + XCTAssertEqual(clamped, TCC(d: 0, h: 23, m: 59, s: 59, f: $0.maxFrameNumberDisplayable), "for \($0)") + + } + + // 100 days + + OTTimecode.FrameRate.allCases.forEach { + + XCTAssertEqual(OTTimecode(clamping: TCC(h: -1, m: -1, s: -1, f: -1), at: $0).components, + TCC(d: 0, h: 0, m: 0, s: 0, f: 0), + "for \($0)") + + } + + OTTimecode.FrameRate.allCases.forEach { + let clamped = OTTimecode(clamping: TCC(h: 99, m: 99, s: 99, f: 10000), at: $0).components + + XCTAssertEqual(clamped, TCC(d: 0, h: 23, m: 59, s: 59, f: $0.maxFrameNumberDisplayable), "for \($0)") + + } + + // 100 days - testing with days + + OTTimecode.FrameRate.allCases.forEach { + + XCTAssertEqual(OTTimecode(clamping: TCC(d: -1, h: -1, m: -1, s: -1, f: -1), at: $0, limit: ._100days).components, + TCC(d: 0, h: 0, m: 0, s: 0, f: 0), + "for \($0)") + + } + + OTTimecode.FrameRate.allCases.forEach { + let clamped = OTTimecode(clamping: TCC(d: 99, h: 99, m: 99, s: 99, f: 10000), at: $0, limit: ._100days).components + + XCTAssertEqual(clamped, TCC(d: 99, h: 23, m: 59, s: 59, f: $0.maxFrameNumberDisplayable), "for \($0)") + + } + + } + + func testOTTimecode_Wrapping() { + + // 24 hour + + OTTimecode.FrameRate.allCases.forEach { + + XCTAssertEqual(OTTimecode(wrapping: TCC(d: 1), at: $0).components, + TCC(d: 0, h: 0, m: 0, s: 0, f: 0), + "for \($0)") + + } + + OTTimecode.FrameRate.allCases.forEach { + let wrapped = OTTimecode(wrapping: TCC(f: -1), at: $0).components + + XCTAssertEqual(wrapped, TCC(d: 0, h: 23, m: 59, s: 59, f: $0.maxFrameNumberDisplayable, sf: 0), "for \($0)") + + } + + // 24 hour - testing with days + + OTTimecode.FrameRate.allCases.forEach { + let wrapped = OTTimecode(wrapping: TCC(d: 1, h: 2, m: 30, s: 20, f: 0), at: $0).components + + XCTAssertEqual(wrapped, TCC(d: 0, h: 2, m: 30, s: 20, f: 0), "for \($0)") + + } + + // 100 days + + OTTimecode.FrameRate.allCases.forEach { + let wrapped = OTTimecode(wrapping: TCC(d: -1), at: $0, limit: ._100days).components + + XCTAssertEqual(wrapped, TCC(d: 99, h: 0, m: 0, s: 0, f: 0), "for \($0)") + + } + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Components/Components Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Components/Components Tests.swift new file mode 100644 index 00000000..53d43006 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Components/Components Tests.swift @@ -0,0 +1,24 @@ +// +// Components Tests.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Components_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testTCC_toTimecode() { + + XCTAssertEqual(TCC(d: 0, h: 1, m: 2, s: 3, f: 4, sf: 0).toTimecode(at: ._23_976), + OTTimecode(TCC(h: 1, m: 2, s: 3, f: 4), at: ._23_976)) + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Components Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Components Tests.swift new file mode 100644 index 00000000..4dc5c8de --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Components Tests.swift @@ -0,0 +1,142 @@ +// +// OTTimecode Components Tests.swift +// OTTimecodeUnitTests +// +// Created by Steffan Andrews on 2020-06-17. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_DI_Components_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testOTTimecode_components_24hours() { + + // default + + var tc = OTTimecode(at: ._30) + + XCTAssertEqual(tc.days , 0) + XCTAssertEqual(tc.hours , 0) + XCTAssertEqual(tc.minutes , 0) + XCTAssertEqual(tc.seconds , 0) + XCTAssertEqual(tc.frames , 0) + XCTAssertEqual(tc.subFrames , 0) + + // setter + + tc.components = TCC(h: 1, m: 2, s: 3, f: 4, sf: 5) + + XCTAssertEqual(tc.days , 0) + XCTAssertEqual(tc.hours , 1) + XCTAssertEqual(tc.minutes , 2) + XCTAssertEqual(tc.seconds , 3) + XCTAssertEqual(tc.frames , 4) + XCTAssertEqual(tc.subFrames , 5) + + // getter + + let c = tc.components + + XCTAssertEqual(c.d , 0) + XCTAssertEqual(c.h , 1) + XCTAssertEqual(c.m , 2) + XCTAssertEqual(c.s , 3) + XCTAssertEqual(c.f , 4) + XCTAssertEqual(c.sf , 5) + + } + + func testOTTimecode_components_100days() { + + // default + + var tc = OTTimecode(at: ._30, limit: ._100days) + + XCTAssertEqual(tc.days , 0) + XCTAssertEqual(tc.hours , 0) + XCTAssertEqual(tc.minutes , 0) + XCTAssertEqual(tc.seconds , 0) + XCTAssertEqual(tc.frames , 0) + XCTAssertEqual(tc.subFrames , 0) + + // setter + + tc.components = TCC(d: 5, h: 1, m: 2, s: 3, f: 4, sf: 5) + + XCTAssertEqual(tc.days , 5) + XCTAssertEqual(tc.hours , 1) + XCTAssertEqual(tc.minutes , 2) + XCTAssertEqual(tc.seconds , 3) + XCTAssertEqual(tc.frames , 4) + XCTAssertEqual(tc.subFrames , 5) + + // getter + + let c = tc.components + + XCTAssertEqual(c.d , 5) + XCTAssertEqual(c.h , 1) + XCTAssertEqual(c.m , 2) + XCTAssertEqual(c.s , 3) + XCTAssertEqual(c.f , 4) + XCTAssertEqual(c.sf , 5) + + } + + func testSetTimecodeExactly() { + + // this is not meant to test the underlying logic, simply that .setTimecode produces the intended outcome + + var tc = OTTimecode(at: ._30) + + tc.setTimecode(exactly: TCC(h: 1, m: 2, s: 3, f: 4, sf: 5)) + + XCTAssertEqual(tc.days , 0) + XCTAssertEqual(tc.hours , 1) + XCTAssertEqual(tc.minutes , 2) + XCTAssertEqual(tc.seconds , 3) + XCTAssertEqual(tc.frames , 4) + XCTAssertEqual(tc.subFrames , 5) + + } + + func testSetTimecodeClamping() { + + // this is not meant to test the underlying logic, simply that .setTimecode produces the intended outcome + + var tc = OTTimecode(at: ._30) + + tc.setTimecode(clamping: TCC(d: 1, h: 70, m: 70, s: 70, f: 70, sf: 500)) + + XCTAssertEqual(tc.days , 0) + XCTAssertEqual(tc.hours , 23) + XCTAssertEqual(tc.minutes , 59) + XCTAssertEqual(tc.seconds , 59) + XCTAssertEqual(tc.frames , 29) + XCTAssertEqual(tc.subFrames , 79) + + } + + func testSetTimecodeWrapping() { + + // this is not meant to test the underlying logic, simply that .setTimecode produces the intended outcome + + var tc = OTTimecode(at: ._30) + + tc.setTimecode(wrapping: TCC(f: -1)) + + XCTAssertEqual(tc.days , 0) + XCTAssertEqual(tc.hours , 23) + XCTAssertEqual(tc.minutes , 59) + XCTAssertEqual(tc.seconds , 59) + XCTAssertEqual(tc.frames , 29) + XCTAssertEqual(tc.subFrames , 0) + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Real Time Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Real Time Tests.swift new file mode 100644 index 00000000..eb9c8509 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Real Time Tests.swift @@ -0,0 +1,151 @@ +// +// OTTimecode Real Time Tests.swift +// OTTimecodeUnitTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_DI_Real_Time_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testOTTimecode_RealTime() { + + // pre-computed constants + + let msIn10Hr_ShrunkFrameRates = 864864000.0 + let msIn10Hr_BaseFrameRates = 864000000.0 + let msIn10Hr_DropFrameRates = 863999136.0 + + + // get real time + + // allow for the over-estimate padding value that gets added in the TC->realtime method + let accuracy = 0.0001 + + OTTimecode.FrameRate.allCases.forEach { + + let tc = OTTimecode(TCC(d: 10), at: $0, limit: ._100days)! + + switch $0 { + case ._23_976, + ._24_98, + ._29_97, + ._47_952, + ._59_94, + ._119_88: + + XCTAssertEqual(tc.realTime.ms, msIn10Hr_ShrunkFrameRates, accuracy: accuracy, "at: \($0)") + + case ._24, + ._25, + ._30, + ._48, + ._50, + ._60, + ._100, + ._120: + + XCTAssertEqual(tc.realTime.ms, msIn10Hr_BaseFrameRates, accuracy: accuracy, "at: \($0)") + + case ._29_97_drop, + ._30_drop, + ._59_94_drop, + ._60_drop, + ._119_88_drop, + ._120_drop: + + XCTAssertEqual(tc.realTime.ms, msIn10Hr_DropFrameRates, accuracy: accuracy, "at: \($0)") + + } + } + + // set timecode from real time + + let tcc = TCC(d: 10) + + OTTimecode.FrameRate.allCases.forEach { + + var tc = OTTimecode(tcc, at: $0, limit: ._100days)! + + switch $0 { + case ._23_976, + ._24_98, + ._29_97, + ._47_952, + ._59_94, + ._119_88: + + XCTAssertTrue(tc.setTimecode(from: OTTime(ms: msIn10Hr_ShrunkFrameRates)), "at: \($0)") + XCTAssertEqual(tc.components, tcc, "at: \($0)") + + case ._24, + ._25, + ._30, + ._48, + ._50, + ._60, + ._100, + ._120: + + XCTAssertTrue(tc.setTimecode(from: OTTime(ms: msIn10Hr_BaseFrameRates)), "at: \($0)") + XCTAssertEqual(tc.components, tcc, "at: \($0)") + + case ._29_97_drop, + ._30_drop, + ._59_94_drop, + ._60_drop, + ._119_88_drop, + ._120_drop: + + XCTAssertTrue(tc.setTimecode(from: OTTime(ms: msIn10Hr_DropFrameRates)), "at: \($0)") + XCTAssertEqual(tc.components, tcc, "at: \($0)") + + } + + } + + } + + func testOTTimecode_RealTime_SubFrames() { + + // ensure subframes are calculated correctly + + // test for precision and rounding issues by iterating every subframe for each frame rate + + for subframe in 0...79 { + + let tcc = TCC(d: 99, h: 23, sf: subframe) + + OTTimecode.FrameRate.allCases.forEach { + + var tc = OTTimecode(tcc, + at: $0, + limit: ._100days, + subFramesDivisor: 80)! + + // timecode to samples + + let realTime = tc.realTime + + // samples to timecode + + XCTAssertTrue(tc.setTimecode(from: realTime), + "at: \($0) subframe: \(subframe)") + + XCTAssertEqual(tc.components, + tcc, + "at: \($0) subframe: \(subframe)") + + } + + } + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Samples Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Samples Tests.swift new file mode 100644 index 00000000..0752e9c9 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode Samples Tests.swift @@ -0,0 +1,164 @@ +// +// OTTimecode Samples Tests.swift +// OTTimecodeUnitTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_DI_Samples_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testSamplesGetSet_48KHz() { + + // pre-computed constants + + let samplesIn1DayTC_ShrunkFrameRates = 4151347200.0 + let samplesIn1DayTC_BaseFrameRates = 4147200000.0 + let samplesIn1DayTC_DropFrameRates = 4147195853.0 + + let samplesIn1DayTC_30DF = 4143052800.0 // confirmed correct in PT and Cubase + + // allow for the over-estimate padding value that gets added in the TC->samples method + let accuracy = 0.001 + + // 48KHz ___________________________________ + + OTTimecode.FrameRate.allCases.forEach { + + let sRate = 48000 + var tc = OTTimecode(at: $0, limit: ._100days) + + switch $0 { + case ._23_976, + ._24_98, + ._29_97, + ._47_952, + ._59_94, + ._119_88: + + // get + tc.setTimecode(exactly: TCC(d: 1)) + XCTAssertEqual(tc.samplesValue(atSampleRate: sRate), + samplesIn1DayTC_ShrunkFrameRates, + accuracy: accuracy, + "at \($0)") + + // set + tc.setTimecode(fromSamplesValue: samplesIn1DayTC_ShrunkFrameRates, + atSampleRate: sRate) + XCTAssertEqual(tc.components, + TCC(d: 1), + "at \($0)") + + case ._24, + ._25, + ._30, + ._48, + ._50, + ._60, + ._100, + ._120: + + // get + tc.setTimecode(exactly: TCC(d: 1)) + XCTAssertEqual(tc.samplesValue(atSampleRate: sRate), + samplesIn1DayTC_BaseFrameRates, + accuracy: accuracy, + "at \($0)") + + // set + tc.setTimecode(fromSamplesValue: samplesIn1DayTC_BaseFrameRates, + atSampleRate: sRate) + XCTAssertEqual(tc.components, + TCC(d: 1), + "at \($0)") + + case ._29_97_drop, + ._59_94_drop, + ._119_88_drop: + + // Cubase reports 4147195853 @ 1 day - there may be rounding happening in Cubase + // Pro Tools reports 2073597926 @ 12 hours; double this would technically be 4147195854 but Cubase shows 1 frame less + + // get + tc.setTimecode(exactly: TCC(d: 1)) + XCTAssertEqual(tc.samplesValue(atSampleRate: sRate).rounded(), // add rounding for dropframe; DAWs seem to round using standard rounding rules (?) + samplesIn1DayTC_DropFrameRates, + "at \($0)") + + // set + tc.setTimecode(fromSamplesValue: samplesIn1DayTC_DropFrameRates, + atSampleRate: sRate) + XCTAssertEqual(tc.components, TCC(d: 1), + "at \($0)") + + case ._30_drop, + ._60_drop, + ._120_drop: + + // get + tc.setTimecode(exactly: TCC(d: 1)) + XCTAssertEqual(tc.samplesValue(atSampleRate: sRate), + samplesIn1DayTC_30DF, + accuracy: accuracy, + "at \($0)") + + // set + tc.setTimecode(fromSamplesValue: samplesIn1DayTC_30DF, + atSampleRate: sRate) + XCTAssertEqual(tc.components, + TCC(d: 1), + "at \($0)") + + } + + } + + } + + func testOTTimecode_Samples_SubFrames() { + + // ensure subframes are calculated correctly + + // test for precision and rounding issues by iterating every subframe for each frame rate + + for subframe in 0...79 { + + let tcc = TCC(d: 99, h: 23, sf: subframe) + + OTTimecode.FrameRate.allCases.forEach { + + var tc = OTTimecode(tcc, + at: $0, + limit: ._100days, + subFramesDivisor: 80)! + + let sRate = 48000 + + // timecode to samples + + let samples = tc.samplesValue(atSampleRate: sRate) + + // samples to timecode + + XCTAssertTrue(tc.setTimecode(fromSamplesValue: samples, + atSampleRate: sRate), + "at: \($0) subframe: \(subframe)") + + XCTAssertEqual(tc.components, + tcc, + "at: \($0) subframe: \(subframe)") + + } + + } + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode String Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode String Tests.swift new file mode 100644 index 00000000..1c0190a2 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Data Interchange/OTTimecode String Tests.swift @@ -0,0 +1,335 @@ +// +// OTTimecode String Tests.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_DI_String_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testStringValue_GetSet_Basic() { + + // basic set & get tests + + var tc = OTTimecode(at: ._23_976, limit: ._24hours) + + tc.stringValue = "01:05:20:14" + XCTAssertEqual(tc.stringValue, "01:05:20:14") + + tc.stringValue = "50:05:20:14" // fails silently + XCTAssertEqual(tc.stringValue, "01:05:20:14") // old value + + XCTAssertFalse(tc.setTimecode(exactly: "50:05:20:14")) + XCTAssertEqual(tc.stringValue, "01:05:20:14") // no change + + XCTAssertTrue(tc.setTimecode(clamping: "50:05:20:14")) + XCTAssertEqual(tc.stringValue, "23:05:20:14") + + } + + func testStringValue_Get_Formatting_Basic() { + + // basic string formatting - ie: HH:MM:SS:FF + // using known valid timecode components; not testing for invalid values here + + // 24 hour limit + + // non-drop + + OTTimecode.FrameRate.allNonDrop.forEach { + let sv = TCC(h: 1, m: 02, s: 03, f: 04) + .toTimecode(at: $0)? + .stringValue + + let t = $0.numberOfDigits == 2 ? "" : "0" + + XCTAssertEqual(sv, "01:02:03:\(t)04", "for \($0)") + } + + // drop + + OTTimecode.FrameRate.allDrop.forEach { + let sv = TCC(h: 1, m: 02, s: 03, f: 04) + .toTimecode(at: $0)? + .stringValue + + let t = $0.numberOfDigits == 2 ? "" : "0" + + XCTAssertEqual(sv, "01:02:03;\(t)04", "for \($0)") + } + + // 100 days limit + + // non-drop + + OTTimecode.FrameRate.allNonDrop.forEach { + let sv = TCC(h: 1, m: 02, s: 03, f: 04) + .toTimecode(at: $0, limit: ._100days)? + .stringValue + + let t = $0.numberOfDigits == 2 ? "" : "0" + + XCTAssertEqual(sv, "01:02:03:\(t)04", "for \($0)") // omits days since they are 0 + } + + // drop + + OTTimecode.FrameRate.allDrop.forEach { + let sv = TCC(h: 1, m: 02, s: 03, f: 04) + .toTimecode(at: $0, limit: ._100days)? + .stringValue + + let t = $0.numberOfDigits == 2 ? "" : "0" + + XCTAssertEqual(sv, "01:02:03;\(t)04", "for \($0)") // omits days since they are 0 + } + } + + func testStringValue_Get_Formatting_WithDays() { + + // string formatting with days - ie: D:HH:MM:SS:FF + // using known valid timecode components; not testing for invalid values here + + // non-drop + + OTTimecode.FrameRate.allNonDrop.forEach { + var tc = TCC(h: 1, m: 02, s: 03, f: 04) + .toTimecode(at: $0) + tc?.days = 2 // set days after init since init fails if we pass days + + let t = $0.numberOfDigits == 2 ? "" : "0" + + // still produces days since we have not clamped it yet + var sv = tc?.stringValue + XCTAssertEqual(sv, "2 01:02:03:\(t)04", "for \($0)") + + // now omits days since our limit is 24hr and clamped + tc!.clampComponents() + sv = tc?.stringValue + XCTAssertEqual(sv, "01:02:03:\(t)04", "for \($0)") + } + + // drop + + OTTimecode.FrameRate.allDrop.forEach { + var tc = TCC(h: 1, m: 02, s: 03, f: 04) + .toTimecode(at: $0) + tc?.days = 2 // set days after init since init fails if we pass days + + let t = $0.numberOfDigits == 2 ? "" : "0" + + // still produces days since we have not clamped it yet + var sv = tc?.stringValue + XCTAssertEqual(sv, "2 01:02:03;\(t)04", "for \($0)") + + // now omits days since our limit is 24hr and clamped + tc?.clampComponents() + sv = tc?.stringValue + XCTAssertEqual(sv, "01:02:03;\(t)04", "for \($0)") + } + + // 100 days limit + + // non-drop + + OTTimecode.FrameRate.allNonDrop.forEach { + let sv = TCC(d: 2, h: 1, m: 02, s: 03, f: 04) + .toTimecode(at: $0, limit: ._100days)? + .stringValue + + let t = $0.numberOfDigits == 2 ? "" : "0" + + XCTAssertEqual(sv, "2 01:02:03:\(t)04", "for \($0)") // omits days since they are 0 + } + + // drop + + OTTimecode.FrameRate.allDrop.forEach { + let sv = TCC(d: 2, h: 1, m: 02, s: 03, f: 04) + .toTimecode(at: $0, limit: ._100days)? + .stringValue + + let t = $0.numberOfDigits == 2 ? "" : "0" + + XCTAssertEqual(sv, "2 01:02:03;\(t)04", "for \($0)") // omits days since they are 0 + } + + } + + func testStringValue_Get_Formatting_WithSubframes() { + + // string formatting with subframes - ie: HH:MM:SS:FF.sf + // using known valid timecode components; not testing for invalid values here + + // non-drop + + OTTimecode.FrameRate.allNonDrop.forEach { + var tc = TCC(h: 1, m: 02, s: 03, f: 04, sf: 12) + .toTimecode(at: $0) + tc?.displaySubFrames = true + tc?.days = 2 // set days after init since init fails if we pass days + + let t = $0.numberOfDigits == 2 ? "" : "0" + + // still produces days since we have not clamped it yet + var sv = tc?.stringValue + XCTAssertEqual(sv, "2 01:02:03:\(t)04.12", "for \($0)") + + // now omits days since our limit is 24hr and clamped + tc!.clampComponents() + sv = tc?.stringValue + XCTAssertEqual(sv, "01:02:03:\(t)04.12", "for \($0)") + } + + // drop + + OTTimecode.FrameRate.allDrop.forEach { + var tc = TCC(h: 1, m: 02, s: 03, f: 04, sf: 12) + .toTimecode(at: $0) + tc?.displaySubFrames = true + tc?.days = 2 // set days after init since init fails if we pass days + + let t = $0.numberOfDigits == 2 ? "" : "0" + + // still produces days since we have not clamped it yet + var sv = tc?.stringValue + XCTAssertEqual(sv, "2 01:02:03;\(t)04.12", "for \($0)") + + // now omits days since our limit is 24hr and clamped + tc?.clampComponents() + sv = tc?.stringValue + XCTAssertEqual(sv, "01:02:03;\(t)04.12", "for \($0)") + } + + // 100 days limit + + // non-drop + + OTTimecode.FrameRate.allNonDrop.forEach { + var tc = TCC(d: 2, h: 1, m: 02, s: 03, f: 04, sf: 12) + .toTimecode(at: $0, limit: ._100days) + tc?.displaySubFrames = true + + let t = $0.numberOfDigits == 2 ? "" : "0" + + let sv = tc?.stringValue + XCTAssertEqual(sv, "2 01:02:03:\(t)04.12", "for \($0)") // omits days since they are 0 + } + + // drop + + OTTimecode.FrameRate.allDrop.forEach { + var tc = TCC(d: 2, h: 1, m: 02, s: 03, f: 04, sf: 12) + .toTimecode(at: $0, limit: ._100days) + tc?.displaySubFrames = true + + let t = $0.numberOfDigits == 2 ? "" : "0" + + let sv = tc?.stringValue + XCTAssertEqual(sv, "2 01:02:03;\(t)04.12", "for \($0)") // omits days since they are 0 + } + + } + + func testStringDecode() { + + // non-drop frame + + XCTAssertNil( OTTimecode.decode(timecode: "")) + XCTAssertNil( OTTimecode.decode(timecode: "01564523")) + XCTAssertEqual(OTTimecode.decode(timecode: "0:0:0:0"), + TCC(d: 0, h: 00, m: 00, s: 00, f: 00, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "0:00:00:00"), + TCC(d: 0, h: 00, m: 00, s: 00, f: 00, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "00:00:00:00"), + TCC(d: 0, h: 00, m: 00, s: 00, f: 00, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "1:56:45:23"), + TCC(d: 0, h: 01, m: 56, s: 45, f: 23, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "01:56:45:23"), + TCC(d: 0, h: 01, m: 56, s: 45, f: 23, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "3 01:56:45:23"), + TCC(d: 3, h: 01, m: 56, s: 45, f: 23, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "12 01:56:45:23"), + TCC(d: 12, h: 1, m: 56, s: 45, f: 23, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "12:01:56:45:23"), + TCC(d: 12, h: 1, m: 56, s: 45, f: 23, sf: 00)) + + // drop frame + + XCTAssertEqual(OTTimecode.decode(timecode: "0:0:0;0"), + TCC(d: 0, h: 00, m: 00, s: 00, f: 00, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "0:00:00;00"), + TCC(d: 0, h: 00, m: 00, s: 00, f: 00, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "00:00:00;00"), + TCC(d: 0, h: 00, m: 00, s: 00, f: 00, sf: 00)) + + XCTAssertEqual(OTTimecode.decode(timecode: "1:56:45;23"), + TCC(d: 0, h: 01, m: 56, s: 45, f: 23, sf: 0)) + + XCTAssertEqual(OTTimecode.decode(timecode: "01:56:45;23"), + TCC(d: 0, h: 01, m: 56, s: 45, f: 23, sf: 0)) + + XCTAssertEqual(OTTimecode.decode(timecode: "3 01:56:45;23"), + TCC(d: 3, h: 01, m: 56, s: 45, f: 23, sf: 0)) + + XCTAssertEqual(OTTimecode.decode(timecode: "12 01:56:45;23"), + TCC(d: 12, h: 01, m: 56, s: 45, f: 23, sf: 0)) + + XCTAssertEqual(OTTimecode.decode(timecode: "12:01:56:45;23"), + TCC(d: 12, h: 01, m: 56, s: 45, f: 23, sf: 0)) + + + // all periods - not supporting this. + + XCTAssertNil(OTTimecode.decode(timecode: "0.0.0.0")) + XCTAssertNil(OTTimecode.decode(timecode: "0.00.00.00")) + XCTAssertNil(OTTimecode.decode(timecode: "00.00.00.00")) + XCTAssertNil(OTTimecode.decode(timecode: "1.56.45.23")) + XCTAssertNil(OTTimecode.decode(timecode: "01.56.45.23")) + XCTAssertNil(OTTimecode.decode(timecode: "3 01.56.45.23")) + XCTAssertNil(OTTimecode.decode(timecode: "12.01.56.45.23")) + XCTAssertNil(OTTimecode.decode(timecode: "12.01.56.45.23")) + + // subframes + + XCTAssertEqual(OTTimecode.decode(timecode: "0:00:00:00.05"), + TCC(d: 0, h: 00, m: 00, s: 00, f: 00, sf: 05)) + + XCTAssertEqual(OTTimecode.decode(timecode: "00:00:00:00.05"), + TCC(d: 0, h: 00, m: 00, s: 00, f: 00, sf: 05)) + + XCTAssertEqual(OTTimecode.decode(timecode: "1:56:45:23.05"), + TCC(d: 0, h: 01, m: 56, s: 45, f: 23, sf: 05)) + + XCTAssertEqual(OTTimecode.decode(timecode: "01:56:45:23.05"), + TCC(d: 0, h: 01, m: 56, s: 45, f: 23, sf: 05)) + + XCTAssertEqual(OTTimecode.decode(timecode: "3 01:56:45:23.05"), + TCC(d: 3, h: 01, m: 56, s: 45, f: 23, sf: 05)) + + XCTAssertEqual(OTTimecode.decode(timecode: "12 01:56:45:23.05"), + TCC(d: 12, h: 01, m: 56, s: 45, f: 23, sf: 05)) + + XCTAssertEqual(OTTimecode.decode(timecode: "12:01:56:45:23.05"), + TCC(d: 12, h: 01, m: 56, s: 45, f: 23, sf: 05)) + + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Math/OTTimecode Math Public Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Math/OTTimecode Math Public Tests.swift new file mode 100644 index 00000000..bccae2f7 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Math/OTTimecode Math Public Tests.swift @@ -0,0 +1,228 @@ +// +// OTTimecode Math Public Tests.swift +// OTTimecodeUnitTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Math_Public_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testAdd_and_Subtract_Methods() { + + // .add / .subtract methods + + var tc = OTTimecode(at: ._23_976, limit: ._24hours) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(h: 00, m: 00, s: 00, f: 23))) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 23)) + XCTAssertTrue (tc.add( TCC(h: 00, m: 00, s: 00, f: 01))) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 01, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(h: 01, m: 15, s: 30, f: 10))) + XCTAssertEqual(tc.components, TCC(h: 01, m: 15, s: 30, f: 10)) + XCTAssertTrue (tc.add( TCC(h: 01, m: 15, s: 30, f: 10))) + XCTAssertEqual(tc.components, TCC(h: 02, m: 31, s: 00, f: 20)) + XCTAssertFalse(tc.add( TCC(h: 23, m: 15, s: 30, f: 10))) + XCTAssertEqual(tc.components, TCC(h: 02, m: 31, s: 00, f: 20)) // unchanged value + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertFalse(tc.subtract( TCC(h: 02, m: 31, s: 00, f: 20))) + + tc = OTTimecode( TCC(h: 23, m: 59, s: 59, f: 23), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.subtract( TCC(h: 23, m: 59, s: 59, f: 23))) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 23, m: 59, s: 59, f: 23), + at: ._23_976, limit: ._24hours)! + XCTAssertFalse(tc.subtract( TCC(h: 23, m: 59, s: 59, f: 24))) // 1 frame too many + XCTAssertEqual(tc.components, TCC(h: 23, m: 59, s: 59, f: 23)) // unchanged value + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(f: 24))) // roll up to 1 sec + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 01, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(s: 60))) // roll up to 1 min + XCTAssertEqual(tc.components, TCC(h: 00, m: 01, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(m: 60))) // roll up to 1 hr + XCTAssertEqual(tc.components, TCC(h: 01, m: 00, s: 00, f: 00)) + + tc = OTTimecode( TCC(d: 0, h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._100days)! + XCTAssertTrue (tc.add( TCC(h: 24))) // roll up to 1 day + XCTAssertEqual(tc.components, TCC(d: 01, h: 00, m: 00, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(h: 00, m: 00, s: 00, f: 2073599))) + XCTAssertEqual(tc.components, TCC(h: 23, m: 59, s: 59, f: 23)) + + tc = OTTimecode( TCC(h: 23, m: 59, s: 59, f: 23), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.subtract( TCC(h: 00, m: 00, s: 00, f: 2073599))) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 00)) + + XCTAssertTrue (tc.add( TCC(h: 00, m: 00, s: 00, f: 200))) + XCTAssertTrue (tc.subtract( TCC(h: 00, m: 00, s: 00, f: 199))) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 01)) + + // clamping + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.add(clamping: TCC(h: 25)) + XCTAssertEqual(tc.components, TCC(h: 23, m: 59, s: 59, f: 23, sf: 79)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.subtract(clamping: TCC(h: 4)) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 00)) + + // wrapping + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.add(wrapping: TCC(h: 25)) + XCTAssertEqual(tc.components, TCC(h: 01, m: 00, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.add(wrapping: TCC(f: -1)) // add negative number + XCTAssertEqual(tc.components, TCC(h: 23, m: 59, s: 59, f: 23)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.subtract(wrapping: TCC(h: 4)) + XCTAssertEqual(tc.components, TCC(h: 20, m: 00, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.subtract(wrapping: TCC(h: -4)) // subtract negative number + XCTAssertEqual(tc.components, TCC(h: 04, m: 00, s: 00, f: 00)) + + // drop-frame frame rates + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._29_97_drop, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(h: 00, m: 00, s: 00, f: 29))) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 29)) + XCTAssertTrue (tc.add( TCC(h: 00, m: 00, s: 00, f: 01))) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 01, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._29_97_drop, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(m: 60))) // roll up to 1 hr + XCTAssertEqual(tc.components, TCC(h: 01, m: 00, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._29_97_drop, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(f: 30))) // roll up to 1 sec + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 01, f: 00)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 59, f: 00), + at: ._29_97_drop, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(f: 30))) // roll up to 1 sec and 2 frames (2 dropped frames every minute except every 10th minute) + XCTAssertEqual(tc.components, TCC(h: 00, m: 01, s: 00, f: 02)) + + tc = OTTimecode( TCC(h: 00, m: 01, s: 00, f: 02), + at: ._29_97_drop, limit: ._24hours)! + XCTAssertTrue (tc.add( TCC(m: 01))) // roll up to 1 sec and 2 frames (2 dropped frames every minute except every 10th minute) + XCTAssertEqual(tc.components, TCC(h: 00, m: 02, s: 00, f: 02)) + XCTAssertTrue (tc.add( TCC(m: 08))) + XCTAssertEqual(tc.components, TCC(h: 00, m: 10, s: 00, f: 00)) + + } + + func testMultiply_and_Divide() { + + // .multiply / .divide methods + + var tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.multiply(2)) + XCTAssertEqual(tc.components, TCC(h: 02, m: 00, s: 00, f: 00)) + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + XCTAssertTrue (tc.multiply(2.5)) + XCTAssertEqual(tc.components, TCC(h: 02, m: 30, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._29_97_drop, limit: ._24hours)! + XCTAssertTrue (tc.multiply(2)) + XCTAssertEqual(tc.components, TCC(h: 02, m: 00, s: 00, f: 00)) + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._29_97_drop, limit: ._24hours)! + XCTAssertTrue (tc.multiply(2.5)) + XCTAssertEqual(tc.components, TCC(h: 02, m: 30, s: 00, f: 00)) + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._29_97_drop, limit: ._24hours)! + XCTAssertFalse(tc.multiply(25)) + XCTAssertEqual(tc.components, TCC(h: 01, m: 00, s: 00, f: 00)) // unchanged + + // clamping + + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.multiply(clamping: 25.0) + XCTAssertEqual(tc.components, TCC(h: 23, m: 59, s: 59, f: 23)) + + tc = OTTimecode( TCC(h: 00, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.divide(clamping: 4) + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 00)) + + // wrapping - multiply + + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.multiply(wrapping: 25.0) + XCTAssertEqual(tc.components, TCC(h: 01, m: 00, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.multiply(wrapping: 2) + XCTAssertEqual(tc.components, TCC(h: 02, m: 00, s: 00, f: 00)) // normal, no wrap + + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.multiply(wrapping: 25) + XCTAssertEqual(tc.components, TCC(h: 01, m: 00, s: 00, f: 00)) // wraps + + // wrapping - divide + + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.divide(wrapping: -2) + XCTAssertEqual(tc.components, TCC(h: 23, m: 30, s: 00, f: 00)) + + tc = OTTimecode( TCC(h: 01, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.divide(wrapping: 2) + XCTAssertEqual(tc.components, TCC(h: 00, m: 30, s: 00, f: 00)) // normal, no wrap + + tc = OTTimecode( TCC(h: 12, m: 00, s: 00, f: 00), + at: ._23_976, limit: ._24hours)! + tc.divide(wrapping: -2) + XCTAssertEqual(tc.components, TCC(h: 18, m: 00, s: 00, f: 00)) // wraps + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Math/OTTimecode Operators Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Math/OTTimecode Operators Tests.swift new file mode 100644 index 00000000..765b6d18 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Math/OTTimecode Operators Tests.swift @@ -0,0 +1,69 @@ +// +// OTTimecode Operators Tests.swift +// OTTimecodeUnitTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Operators_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testAdd_and_Subtract_Operators() { + + var tc = OTTimecode(at: ._30) + + // + and - operators + + tc = TCC(h: 00, m: 00, s: 00, f: 00).toTimecode(at: ._30)! + + tc = tc + TCC(h: 00, m: 00, s: 00, f: 05).toTimecode(at: ._30)! + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 05)) + + tc = tc - TCC(h: 00, m: 00, s: 00, f: 04).toTimecode(at: ._30)! + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 01)) + + // += and -= operators + + tc = TCC(h: 00, m: 00, s: 00, f: 00).toTimecode(at: ._30)! + + tc += TCC(h: 00, m: 00, s: 00, f: 05).toTimecode(at: ._30)! + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 05)) + + tc -= TCC(h: 00, m: 00, s: 00, f: 04).toTimecode(at: ._30)! + XCTAssertEqual(tc.components, TCC(h: 00, m: 00, s: 00, f: 01)) + + } + + func testMultiply_and_Divide_Operators() { + + var tc = OTTimecode(at: ._30) + + // * and / operators + + tc = TCC(h: 01, m: 00, s: 00, f: 00).toTimecode(at: ._30)! + + tc = tc * 5 + XCTAssertEqual(tc.components, TCC(h: 05, m: 00, s: 00, f: 00)) + + tc = tc / 5 + XCTAssertEqual(tc.components, TCC(h: 01, m: 00, s: 00, f: 00)) + + // += and -= operators + + tc = TCC(h: 01, m: 00, s: 00, f: 00).toTimecode(at: ._30)! + + tc *= 5 + XCTAssertEqual(tc.components, TCC(h: 05, m: 00, s: 00, f: 00)) + + tc /= 5 + XCTAssertEqual(tc.components, TCC(h: 01, m: 00, s: 00, f: 00)) + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTime/OTTime Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTime/OTTime Tests.swift new file mode 100644 index 00000000..59959e59 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTime/OTTime Tests.swift @@ -0,0 +1,65 @@ +// +// OTTime Tests.swift +// OTTimecodeUnitTests +// +// Created by Steffan Andrews on 2020-06-18. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTime_UT_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testMS() { + + let time = 50505050.123456789123456789 + + let ott = OTTime(ms: time) + + XCTAssertEqual(ott.backing, .ms) + XCTAssertEqual(ott.ms, time) + XCTAssertEqual(ott.seconds, time / 1000.0) + + } + + func testSeconds() { + + let time = 50505050.123456789123456789 + + let ott = OTTime(seconds: time) + + XCTAssertEqual(ott.backing, .seconds) + XCTAssertEqual(ott.ms, time * 1000.0) + XCTAssertEqual(ott.seconds, time) + + } + + func testEquatable() { + + let ott1 = OTTime(seconds: 5.5) + let ott2 = OTTime(seconds: 5.5) + + XCTAssertEqual(ott1, ott2) + + let ott3 = OTTime(seconds: 5.5) + let ott4 = OTTime(ms: 5500) + + XCTAssertEqual(ott3, ott4) + + } + + func testComparable() { + + let ott1 = OTTime(seconds: 5.5) + let ott2 = OTTime(seconds: 6.0) + + XCTAssertTrue(ott1 < ott2) + XCTAssertTrue(ott2 > ott1) + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Elapsed Frames.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Elapsed Frames.swift new file mode 100644 index 00000000..6d8a1ae0 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Elapsed Frames.swift @@ -0,0 +1,95 @@ +// +// OTTimecode Elapsed Frames.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Elapsed_Frames: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testAllFrameRates_ElapsedFrames() { + + // duration of 24 hours elapsed, rolling over to 1 day + + // also helps ensure Strideable .distance(to:) returns the correct values + + OTTimecode.FrameRate.allCases.forEach { + + // max frames in 24 hours + + var maxFramesIn24hours = 0 + switch $0 { + case ._23_976 : maxFramesIn24hours = 2073600 + case ._24 : maxFramesIn24hours = 2073600 + case ._24_98 : maxFramesIn24hours = 2160000 + case ._25 : maxFramesIn24hours = 2160000 + case ._29_97 : maxFramesIn24hours = 2592000 + case ._29_97_drop : maxFramesIn24hours = 2589408 + case ._30 : maxFramesIn24hours = 2592000 + case ._30_drop : maxFramesIn24hours = 2589408 + case ._47_952 : maxFramesIn24hours = 4147200 + case ._48 : maxFramesIn24hours = 4147200 + case ._50 : maxFramesIn24hours = 4320000 + case ._59_94 : maxFramesIn24hours = 5184000 + case ._59_94_drop : maxFramesIn24hours = 5178816 + case ._60 : maxFramesIn24hours = 5184000 + case ._60_drop : maxFramesIn24hours = 5178816 + case ._100 : maxFramesIn24hours = 8640000 + case ._119_88 : maxFramesIn24hours = 10368000 + case ._119_88_drop : maxFramesIn24hours = 10357632 + case ._120 : maxFramesIn24hours = 10368000 + case ._120_drop : maxFramesIn24hours = 10357632 + } + + XCTAssertEqual($0.maxTotalFrames(in: ._24hours), + maxFramesIn24hours, + "for \($0)") + + } + + // number of total elapsed frames in (24 hours - 1 frame), or essentially the maximum timecode expressable for each frame rate + + OTTimecode.FrameRate.allCases.forEach { + + // max frames in 24 hours - 1 + + var maxFramesExpressibleIn24hours = 0 + switch $0 { + case ._23_976 : maxFramesExpressibleIn24hours = 2073600 - 1 + case ._24 : maxFramesExpressibleIn24hours = 2073600 - 1 + case ._24_98 : maxFramesExpressibleIn24hours = 2160000 - 1 + case ._25 : maxFramesExpressibleIn24hours = 2160000 - 1 + case ._29_97 : maxFramesExpressibleIn24hours = 2592000 - 1 + case ._29_97_drop : maxFramesExpressibleIn24hours = 2589408 - 1 + case ._30 : maxFramesExpressibleIn24hours = 2592000 - 1 + case ._30_drop : maxFramesExpressibleIn24hours = 2589408 - 1 + case ._47_952 : maxFramesExpressibleIn24hours = 4147200 - 1 + case ._48 : maxFramesExpressibleIn24hours = 4147200 - 1 + case ._50 : maxFramesExpressibleIn24hours = 4320000 - 1 + case ._59_94 : maxFramesExpressibleIn24hours = 5184000 - 1 + case ._59_94_drop : maxFramesExpressibleIn24hours = 5178816 - 1 + case ._60 : maxFramesExpressibleIn24hours = 5184000 - 1 + case ._60_drop : maxFramesExpressibleIn24hours = 5178816 - 1 + case ._100 : maxFramesExpressibleIn24hours = 8640000 - 1 + case ._119_88 : maxFramesExpressibleIn24hours = 10368000 - 1 + case ._119_88_drop : maxFramesExpressibleIn24hours = 10357632 - 1 + case ._120 : maxFramesExpressibleIn24hours = 10368000 - 1 + case ._120_drop : maxFramesExpressibleIn24hours = 10357632 - 1 + } + + XCTAssertEqual($0.maxTotalFramesExpressible(in: ._24hours), + maxFramesExpressibleIn24hours, + "for \($0)") + + } + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode String Extensions Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode String Extensions Tests.swift new file mode 100644 index 00000000..78c4df4b --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode String Extensions Tests.swift @@ -0,0 +1,25 @@ +// +// OTTimecode String Extensions Tests.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Swift_Extensions_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + + func testString_toTimeCode() { + + XCTAssertEqual("01:05:20:14".toTimecode(at: ._23_976), + OTTimecode(TCC(h: 1, m: 5, s: 20, f: 14), at: ._23_976)) + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Tests.swift new file mode 100644 index 00000000..b00e269b --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Tests.swift @@ -0,0 +1,31 @@ +// +// OTTimecode Tests.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testCustomStringConvertible() { + + let tc = OTTimecode(TCC(d: 1, h: 2, m: 3, s: 4, f: 5, sf: 6), + at: ._24, + limit: ._100days)! + + XCTAssertNotEqual(tc.description, "") + + XCTAssertNotEqual(tc.debugDescription, "") + + XCTAssertNotEqual(tc.verboseDescription, "") + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Validation Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Validation Tests.swift new file mode 100644 index 00000000..bf68c1f4 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode Validation Tests.swift @@ -0,0 +1,150 @@ +// +// OTTimecode Validation Tests.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Validation_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testValidWithinRanges() { + + // typical valid values + + let fr = OTTimecode.FrameRate._24 + let limit = OTTimecode.UpperLimit._24hours + + let tc = OTTimecode(at: fr, limit: limit) + + XCTAssertEqual(tc.invalidComponents, []) + XCTAssertEqual(tc.components.invalidComponents(at: fr, limit: limit, subFramesDivisor: 80), []) + + XCTAssertEqual(tc.validRange(of: .days), 0...0) + XCTAssertEqual(tc.validRange(of: .hours), 0...23) + XCTAssertEqual(tc.validRange(of: .minutes), 0...59) + XCTAssertEqual(tc.validRange(of: .seconds), 0...59) + XCTAssertEqual(tc.validRange(of: .frames), 0...23) + //XCTAssertThrowsError(tc.validRange(of: .subFrames)) // ***** + + } + + func testInvalidOverRanges() { + + // invalid - over ranges + + let fr = OTTimecode.FrameRate._24 + let limit = OTTimecode.UpperLimit._24hours + + var tc = OTTimecode(at: fr, limit: limit) + tc.days = 5 + tc.hours = 25 + tc.minutes = 75 + tc.seconds = 75 + tc.frames = 52 + tc.subFrames = 500 + + XCTAssertEqual(tc.invalidComponents, + [.days, .hours, .minutes, .seconds, .frames, .subFrames]) + XCTAssertEqual(tc.components.invalidComponents(at: fr, limit: limit, subFramesDivisor: 80), + [.days, .hours, .minutes, .seconds, .frames, .subFrames]) + + } + + func testInvalidUnderRanges() { + + // invalid - under ranges + + let fr = OTTimecode.FrameRate._24 + let limit = OTTimecode.UpperLimit._24hours + + var tc = OTTimecode(at: fr, limit: limit) + tc.days = -1 + tc.hours = -1 + tc.minutes = -1 + tc.seconds = -1 + tc.frames = -1 + tc.subFrames = -1 + + XCTAssertEqual(tc.invalidComponents, + [.days, .hours, .minutes, .seconds, .frames, .subFrames]) + XCTAssertEqual(tc.components.invalidComponents(at: fr, limit: limit, subFramesDivisor: 80), + [.days, .hours, .minutes, .seconds, .frames, .subFrames]) + + } + + func testDropFrame() { + + // perform a spot-check to ensure drop-frame timecode validation works as expected + + OTTimecode.FrameRate.allDrop.forEach { + + let limit = OTTimecode.UpperLimit._24hours + + // every 10 minutes, no frames are skipped + + do { + var tc = OTTimecode(at: $0, limit: limit) + tc.minutes = 0 + tc.frames = 0 + + XCTAssertEqual(tc.invalidComponents, + [], "for \($0)") + XCTAssertEqual(tc.components.invalidComponents(at: $0, limit: limit, subFramesDivisor: 80), + [], "for \($0)") + } + + // all other minutes each skip frame 0 and 1 + + for minute in 1...9 { + var tc = OTTimecode(at: $0, limit: limit) + tc.minutes = minute + tc.frames = 0 + + XCTAssertEqual(tc.invalidComponents, + [.frames], "for \($0) at \(minute) minutes") + XCTAssertEqual(tc.components.invalidComponents(at: $0, limit: limit, subFramesDivisor: 80), + [.frames], "for \($0) at \(minute) minutes") + + tc = OTTimecode(at: $0, limit: limit) + tc.minutes = minute + tc.frames = 1 + + XCTAssertEqual(tc.invalidComponents, + [.frames], "for \($0) at \(minute) minutes") + XCTAssertEqual(tc.components.invalidComponents(at: $0, limit: limit, subFramesDivisor: 80), + [.frames], "for \($0) at \(minute) minutes") + } + + } + + } + + func testMaxFrames() { + + let tc = OTTimecode(at: ._24, limit: ._24hours) + + let mf = tc.maxFramesAndSubframesExpressible + + let tcc = OTTimecode.components(from: mf, + at: tc.frameRate, + subFramesDivisor: tc.subFramesDivisor) + + XCTAssertEqual(tc.validRange(of: .subFrames), 0...79) + XCTAssertEqual(mf, 2073599.9875) + XCTAssertEqual(tc.subFrames, 0) + XCTAssertEqual(tc.subFramesDivisor, 80) + + XCTAssertEqual(tcc, TCC(d: 0, h: 23, m: 59, s: 59, f: 23, sf: 79)) + + } + +} + + diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode init Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode init Tests.swift new file mode 100644 index 00000000..4b105cb3 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/OTTimecode init Tests.swift @@ -0,0 +1,95 @@ +// +// OTTimecode init Tests.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_init_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + + func testOTTimecode_init_Defaults() { + // essential inits + + // defaults + + var tc = OTTimecode(at: ._24) + XCTAssertEqual(tc.frameRate, ._24) + XCTAssertEqual(tc.upperLimit, ._24hours) + XCTAssertEqual(tc.totalElapsedFrames, 0) + XCTAssertEqual(tc.components, TCC(d: 0, h: 0, m: 0, s: 0, f: 0)) + XCTAssertEqual(tc.stringValue, "00:00:00:00") + + // expected intitalizers + + tc = OTTimecode(at: ._24) + tc = OTTimecode(at: ._24, limit: ._24hours) + + } + + // ____ basic inits, using (exactly: ) ____ + + func testOTTimecode_init_String() { + + OTTimecode.FrameRate.allCases.forEach { + let tc = OTTimecode("00:00:00:00", at: $0, limit: ._24hours) + + XCTAssertEqual(tc?.days , 0 , "for \($0)") + XCTAssertEqual(tc?.hours , 0 , "for \($0)") + XCTAssertEqual(tc?.minutes , 0 , "for \($0)") + XCTAssertEqual(tc?.seconds , 0 , "for \($0)") + XCTAssertEqual(tc?.frames , 0 , "for \($0)") + XCTAssertEqual(tc?.subFrames, 0 , "for \($0)") + } + + OTTimecode.FrameRate.allCases.forEach { + let tc = OTTimecode("01:02:03:04", at: $0, limit: ._24hours) + + XCTAssertEqual(tc?.days , 0 , "for \($0)") + XCTAssertEqual(tc?.hours , 1 , "for \($0)") + XCTAssertEqual(tc?.minutes , 2 , "for \($0)") + XCTAssertEqual(tc?.seconds , 3 , "for \($0)") + XCTAssertEqual(tc?.frames , 4 , "for \($0)") + XCTAssertEqual(tc?.subFrames, 0 , "for \($0)") + } + + } + + func testOTTimecode_init_Components() { + + OTTimecode.FrameRate.allCases.forEach { + let tc = OTTimecode(TCC(d: 0, h: 0, m: 0, s: 0, f: 0), + at: $0, + limit: ._24hours) + + XCTAssertEqual(tc?.days , 0 , "for \($0)") + XCTAssertEqual(tc?.hours , 0 , "for \($0)") + XCTAssertEqual(tc?.minutes , 0 , "for \($0)") + XCTAssertEqual(tc?.seconds , 0 , "for \($0)") + XCTAssertEqual(tc?.frames , 0 , "for \($0)") + XCTAssertEqual(tc?.subFrames, 0 , "for \($0)") + } + + OTTimecode.FrameRate.allCases.forEach { + let tc = OTTimecode(TCC(d: 0, h: 1, m: 2, s: 3, f: 4), + at: $0, + limit: ._24hours) + + XCTAssertEqual(tc?.days , 0 , "for \($0)") + XCTAssertEqual(tc?.hours , 1 , "for \($0)") + XCTAssertEqual(tc?.minutes , 2 , "for \($0)") + XCTAssertEqual(tc?.seconds , 3 , "for \($0)") + XCTAssertEqual(tc?.frames , 4 , "for \($0)") + XCTAssertEqual(tc?.subFrames, 0 , "for \($0)") + } + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Comparable Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Comparable Tests.swift new file mode 100644 index 00000000..70595fcb --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Comparable Tests.swift @@ -0,0 +1,37 @@ +// +// Comparable Tests.swift +// OTTimecodeUnitTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Comparable_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testOTTimecode_Equatable_Comparable() { + + // == + + XCTAssertEqual( "01:00:00:00".toTimecode(at: ._23_976)!, "01:00:00:00".toTimecode(at: ._23_976)!) + XCTAssertEqual( "01:00:00:00".toTimecode(at: ._23_976)!, "01:00:00:00".toTimecode(at: ._29_97)!) + + // == where elapsed frame count matches but frame rate differs (two frame rates where elapsed frames in 24 hours is identical) + + XCTAssertNotEqual("01:00:00:00".toTimecode(at: ._23_976)!, "01:00:00:00".toTimecode(at: ._24)!) + + // < > + + XCTAssertFalse( "01:00:00:00".toTimecode(at: ._23_976)! < "01:00:00:00".toTimecode(at: ._29_97)!) // false because they're == + XCTAssertFalse( "01:00:00:00".toTimecode(at: ._23_976)! > "01:00:00:00".toTimecode(at: ._29_97)!) // false because they're == + + XCTAssertFalse( "01:00:00:00".toTimecode(at: ._23_976)! < "01:00:00:00".toTimecode(at: ._23_976)!) // false because they're == + XCTAssertFalse( "01:00:00:00".toTimecode(at: ._23_976)! > "01:00:00:00".toTimecode(at: ._23_976)!) // false because they're == + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Hashable Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Hashable Tests.swift new file mode 100644 index 00000000..bcc54b76 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Hashable Tests.swift @@ -0,0 +1,67 @@ +// +// Hashable Tests.swift +// OTTimecodeUnitTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Hashable_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testHashValue() { + + // hashValues should be equal + + XCTAssertEqual( "01:00:00:00".toTimecode(at: ._23_976)!.hashValue, + "01:00:00:00".toTimecode(at: ._23_976)!.hashValue) + XCTAssertNotEqual("01:00:00:01".toTimecode(at: ._23_976)!.hashValue, + "01:00:00:00".toTimecode(at: ._23_976)!.hashValue) + + XCTAssertNotEqual("01:00:00:00".toTimecode(at: ._23_976)!.hashValue, + "01:00:00:00".toTimecode(at: ._24)!.hashValue) + XCTAssertNotEqual("01:00:00:00".toTimecode(at: ._23_976)!.hashValue, + "01:00:00:00".toTimecode(at: ._29_97)!.hashValue) + + } + + func testDictionary() { + + // Dictionary / Set + + var dict: [OTTimecode : String] = [:] + dict["01:00:00:00".toTimecode(at: ._23_976)!] = "A Spot Note Here" + dict["01:00:00:06".toTimecode(at: ._23_976)!] = "A Spot Note Also Here" + XCTAssertEqual(dict.count, 2) + dict["01:00:00:00".toTimecode(at: ._24)!] = "This should not replace" + XCTAssertEqual(dict.count, 3) + + XCTAssertEqual(dict["01:00:00:00".toTimecode(at: ._23_976)!], "A Spot Note Here") + XCTAssertEqual(dict["01:00:00:00".toTimecode(at: ._24)!], "This should not replace") + + } + + func testSet() { + + // unique timecodes are based on frame counts, irrespective of frame rate + + let tcSet: Set = ["01:00:00:00".toTimecode(at: ._23_976)!, + "01:00:00:00".toTimecode(at: ._24)!, + "01:00:00:00".toTimecode(at: ._25)!, + "01:00:00:00".toTimecode(at: ._29_97)!, + "01:00:00:00".toTimecode(at: ._29_97_drop)!, + "01:00:00:00".toTimecode(at: ._30)!, + "01:00:00:00".toTimecode(at: ._59_94)!, + "01:00:00:00".toTimecode(at: ._59_94_drop)!, + "01:00:00:00".toTimecode(at: ._60)!] + + XCTAssertNotEqual(tcSet.count, 1) // doesn't matter what frame rate it is, the same total elapsed frames matters + + } + +} diff --git a/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Strideable Tests.swift b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Strideable Tests.swift new file mode 100644 index 00000000..25f07a91 --- /dev/null +++ b/Tests/SwiftTimecode-Unit-Tests/Unit Tests/Protocol Adoptions/Strideable Tests.swift @@ -0,0 +1,137 @@ +// +// Strideable Tests.swift +// SwiftTimecodeTests +// +// Created by Steffan Andrews on 2020-06-16. +// Copyright © 2020 Steffan Andrews. All rights reserved. +// + +import XCTest +@testable import SwiftTimecode + +class OTTimecode_UT_Strideable_Tests: XCTestCase { + + override func setUp() { } + override func tearDown() { } + + func testAdvancedBy() { + + OTTimecode.FrameRate.allCases.forEach { + + let frames = Int(OTTimecode.totalElapsedFrames(of: TCC(h: 1), at: $0)) + + let advanced = TCC(f: 00) + .toTimecode(at: $0)! + .advanced(by: frames) + .components + + XCTAssertEqual(advanced, TCC(h: 1), "for \($0)") + + } + } + + func testDistanceTo_24Hours() { + + // 24 hours stride frame count test + + OTTimecode.FrameRate.allCases.forEach { + + let zero = TCC(h: 00, m: 00, s: 00, f: 00) + .toTimecode(at: $0)! + + let target = TCC(d: 00, h: 23, m: 59, s: 59, f: $0.maxFrameNumberDisplayable) + .toTimecode(at: $0)! + + let delta = zero.distance(to: target) + + XCTAssertEqual(delta, $0.maxTotalFramesExpressible(in: ._24hours), "for \($0)") + + } + + } + + func testDistanceTo_100Days() { + + // 100 days stride frame count test + + OTTimecode.FrameRate.allCases.forEach { + + let zero = TCC(h: 00, m: 00, s: 00, f: 00) + .toTimecode(at: $0, limit: ._100days)! + + let target = TCC(d: 99, h: 23, m: 59, s: 59, f: $0.maxFrameNumberDisplayable) + .toTimecode(at: $0, limit: ._100days)! + + let delta = zero.distance(to: target) + + XCTAssertEqual(delta, $0.maxTotalFramesExpressible(in: ._100days), "for \($0)") + + } + + } + + + // MARK: Integration Tests + + func testOTTimecode_Strideable_Ranges() { + // Stride through & array + + let strideThrough = stride(from: "01:00:00:00".toTimecode(at: ._23_976)!, through: "01:00:00:06".toTimecode(at: ._23_976)!, by: 2) + var array = Array(strideThrough) + XCTAssertEqual(array.count, 4) + XCTAssertEqual(array, ["01:00:00:00".toTimecode(at: ._23_976)!, "01:00:00:02".toTimecode(at: ._23_976)!, "01:00:00:04".toTimecode(at: ._23_976)!, "01:00:00:06".toTimecode(at: ._23_976)!]) + + + // Stride to + let strideTo = stride(from: "01:00:00:00".toTimecode(at: ._23_976)!, to: "01:00:00:06".toTimecode(at: ._23_976)!, by: 2) + array = Array(strideTo) + XCTAssertEqual(array.count, 3) + XCTAssertEqual(array, ["01:00:00:00".toTimecode(at: ._23_976)!, "01:00:00:02".toTimecode(at: ._23_976)!, "01:00:00:04".toTimecode(at: ._23_976)!]) + + + // Strideable + + XCTAssertEqual("01:00:00:00".toTimecode(at: ._23_976)!.advanced(by: 6), + "01:00:00:06".toTimecode(at: ._23_976)!) + + XCTAssertEqual("01:00:00:00".toTimecode(at: ._23_976)!.distance(to: "02:00:00:00".toTimecode(at: ._23_976)!), + Int("01:00:00:00".toTimecode(at: ._23_976)!.totalElapsedFrames)) + + let strs = Array( + stride(from: "01:00:00:05".toTimecode(at: ._23_976)!, through: "01:00:10:05".toTimecode(at: ._23_976)!, + by: Int(OTTimecode(TCC(s: 1), at: ._23_976)!.totalElapsedFrames)) + ).map({ $0.stringValue }) + XCTAssertEqual(strs.count, 11) + + let strs2 = Array( + stride(from: "01:00:00:05".toTimecode(at: ._23_976)!, to: "01:00:10:07".toTimecode(at: ._23_976)!, + by: Int(OTTimecode(TCC(s: 1), at: ._23_976)!.totalElapsedFrames)) + ).map({ $0.stringValue }) + XCTAssertEqual(strs2.count, 11) + + // Strideable with drop-frames + + // TODO: ***** add strideable drop-frame tests + + // Range .contains + + XCTAssertTrue(("01:00:00:00".toTimecode(at: ._23_976)!..."01:00:00:06".toTimecode(at: ._23_976)!).contains(OTTimecode("01:00:00:02", at: ._23_976)!)) + XCTAssertFalse(("01:00:00:00".toTimecode(at: ._23_976)!..."01:00:00:06".toTimecode(at: ._23_976)!).contains(OTTimecode("01:00:00:10", at: ._23_976)!)) + XCTAssertTrue(("01:00:00:00".toTimecode(at: ._23_976)!...).contains(OTTimecode("01:00:00:02", at: ._23_976)!)) + XCTAssertTrue((..."01:00:00:06".toTimecode(at: ._23_976)!).contains(OTTimecode("01:00:00:02", at: ._23_976)!)) + + // (same tests, but with ~= operator instead of .contains(...) which should produce the same result) + + XCTAssertTrue("01:00:00:00".toTimecode(at: ._23_976)!..."01:00:00:06".toTimecode(at: ._23_976)! ~= OTTimecode("01:00:00:02", at: ._23_976)!) + XCTAssertFalse("01:00:00:00".toTimecode(at: ._23_976)!..."01:00:00:06".toTimecode(at: ._23_976)! ~= OTTimecode("01:00:00:10", at: ._23_976)!) + XCTAssertTrue("01:00:00:00".toTimecode(at: ._23_976)!... ~= OTTimecode("01:00:00:02", at: ._23_976)!) + XCTAssertTrue(..."01:00:00:06".toTimecode(at: ._23_976)! ~= OTTimecode("01:00:00:02", at: ._23_976)!) + + // sort + + let sort = ["01:00:00:06".toTimecode(at: ._23_976)!, "01:00:00:00".toTimecode(at: ._23_976)!].sorted() + XCTAssertEqual(sort[0], "01:00:00:00".toTimecode(at: ._23_976)!) + + } + +}