From a6e53f773736d749574d8948070ceb02847de7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Thu, 13 Aug 2026 10:31:17 +0200 Subject: [PATCH 01/12] experiment: platform-conditional TimecodeTotalCount --- .../Timecode/FrameCount/FrameCount.swift | 20 ++++++------ .../Math/Timecode Math Internal.swift | 16 +++++----- .../Timecode/Source/Timecode Rational.swift | 6 +++- .../Timecode/Source/Timecode Samples.swift | 8 ++--- .../Timecode/Timecode Validation.swift | 2 +- .../TimecodeFrameRate Properties.swift | 6 ++-- .../Utilities/TotalCount.swift | 31 +++++++++++++++++++ 7 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 Sources/SwiftTimecodeCore/Utilities/TotalCount.swift diff --git a/Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift b/Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift index 400e1f02..3db232d3 100644 --- a/Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift +++ b/Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift @@ -105,7 +105,7 @@ extension Timecode { extension Timecode.FrameCount { init( - subFrameCount: Int, + subFrameCount: TimecodeTotalCount, base: Timecode.SubFramesBase ) { let converted = Timecode.subFramesToFrames( @@ -225,7 +225,7 @@ extension Timecode.FrameCount { public func multiplying(by factor: Double) -> Self { let lhsTotalSubFrames = subFrameCount - let resultSubFrameCount = Int(Double(lhsTotalSubFrames) * factor) + let resultSubFrameCount = TimecodeTotalCount(Double(lhsTotalSubFrames) * factor) let newFrames = Timecode.subFramesToFrames( resultSubFrameCount, @@ -241,7 +241,7 @@ extension Timecode.FrameCount { public func dividing(by divisor: Double) -> Self { let lhsTotalSubFrames = subFrameCount - let resultSubFrameCount = Int(Double(lhsTotalSubFrames) / divisor) + let resultSubFrameCount = TimecodeTotalCount(Double(lhsTotalSubFrames) / divisor) let newFrames = Timecode.subFramesToFrames( resultSubFrameCount, @@ -268,7 +268,7 @@ extension Timecode.FrameCount { } extension Timecode.FrameCount { - var subFrameCount: Int { + var subFrameCount: TimecodeTotalCount { Timecode.framesToSubFrames( frames: wholeFrames, subFrames: subFrames, @@ -285,15 +285,15 @@ extension Timecode { frames: Int, subFrames: Int, base: SubFramesBase - ) -> Int { - (frames * base.rawValue) + subFrames + ) -> TimecodeTotalCount { + (TimecodeTotalCount(frames) * TimecodeTotalCount(base.rawValue)) + TimecodeTotalCount(subFrames) } /// Internal utility - static func subFramesToFrames(_ subFrames: Int, base: SubFramesBase) -> (frames: Int, subFrames: Int) { - let outSubFrames = subFrames % base.rawValue - let outFrames = (subFrames - outSubFrames) / base.rawValue + static func subFramesToFrames(_ subFrames: TimecodeTotalCount, base: SubFramesBase) -> (frames: Int, subFrames: Int) { + let outSubFrames = subFrames % TimecodeTotalCount(base.rawValue) + let outFrames = (subFrames - outSubFrames) / TimecodeTotalCount(base.rawValue) - return (frames: outFrames, subFrames: outSubFrames) + return (frames: Int(outFrames), subFrames: Int(outSubFrames)) } } diff --git a/Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift b/Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift index 72332677..a7cc2cbb 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift @@ -311,7 +311,7 @@ extension Timecode { if sfcNew > Double(maxSubFrameCountExpressible) { return nil } let fcNew = FrameCount( - subFrameCount: Int(sfcNew), + subFrameCount: TimecodeTotalCount(sfcNew), base: subFramesBase ) @@ -333,7 +333,7 @@ extension Timecode { base: subFramesBase ) - var sfcNew = Int(Double(fcOrigin.subFrameCount) * factor) + var sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) * factor) sfcNew = sfcNew.clamped(to: 0 ... maxSubFrameCountExpressible) @@ -360,7 +360,7 @@ extension Timecode { base: subFramesBase ) - var sfcNew = Int(Double(fcOrigin.subFrameCount) * factor) + var sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) * factor) let maxTotalSubFrames = frameRate.maxTotalSubFrames( in: upperLimit, @@ -401,7 +401,7 @@ extension Timecode { base: subFramesBase ) - let sfcNew = Int(Double(fcOrigin.subFrameCount) * factor) + let sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) * factor) let fcNew = FrameCount( subFrameCount: sfcNew, @@ -434,7 +434,7 @@ extension Timecode { if sfcNew > Double(maxSubFrameCountExpressible) { return nil } let fcNew = FrameCount( - subFrameCount: Int(sfcNew), + subFrameCount: TimecodeTotalCount(sfcNew), base: subFramesBase ) @@ -456,7 +456,7 @@ extension Timecode { base: subFramesBase ) - var sfcNew = Int(Double(fcOrigin.subFrameCount) / divisor) + var sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) / divisor) sfcNew = sfcNew.clamped(to: 0 ... maxSubFrameCountExpressible) @@ -483,7 +483,7 @@ extension Timecode { base: subFramesBase ) - var sfcNew = Int(Double(fcOrigin.subFrameCount) / divisor) + var sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) / divisor) let maxTotalSubFrames = frameRate.maxTotalSubFrames( in: upperLimit, @@ -524,7 +524,7 @@ extension Timecode { base: subFramesBase ) - let sfcNew = Int(Double(fcOrigin.subFrameCount) / divisor) + let sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) / divisor) let fcNew = FrameCount( subFrameCount: sfcNew, diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift index c6760427..c27d5665 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift @@ -50,7 +50,11 @@ extension Timecode { /// fractions.) public var rationalValue: Fraction { let frFrac = frameRate.frameDuration - let n = frFrac.numerator * frameCount.subFrameCount + // `Fraction` is Int-based. On a 32-bit platform a timecode beyond + // ~Int32.max subframes therefore has no representable rational value — + // a limit of `Fraction`, not of the count. Widening `Fraction` belongs + // with the broader consistency work rather than with this fix. + let n = frFrac.numerator * Int(clamping: frameCount.subFrameCount) let d = frFrac.denominator * subFramesBase.rawValue return Fraction(n, d).reduced() diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift index 835e3bca..ee58c38f 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift @@ -34,7 +34,7 @@ extension SamplesPayload: _TimecodeSource { extension TimecodeSourceValue { /// Audio samples at a given sample rate. - public static func samples(_ samples: Int, sampleRate: Int) -> Self { + public static func samples(_ samples: TimecodeTotalCount, sampleRate: Int) -> Self { .init(value: SamplesPayload(samples: Double(samples), sampleRate: sampleRate)) } @@ -51,14 +51,14 @@ extension Timecode { /// Returns the current timecode converted to a duration in audio samples /// at the given sample rate, rounded to the nearest sample. /// Sample rate is expressed in Hz. (ie: 48KHz would be passed as 48000) - public func samplesValue(sampleRate: Int) -> Int { + public func samplesValue(sampleRate: Int) -> TimecodeTotalCount { let val = samplesDoubleValue(sampleRate: sampleRate).rounded() // avoid crash if Double is too big - guard val <= Double(Int.max) else { + guard val <= Double(TimecodeTotalCount.max) else { // assertionFailure("Timecode is too large to convert to audio samples. This will fail silently in a release build.") return 0 } - return Int(val) + return TimecodeTotalCount(val) } /// (Lossy) diff --git a/Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift b/Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift index a063a625..08061fd7 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift @@ -197,7 +197,7 @@ extension Timecode { } /// Returns the `upperLimit` minus 1 subframe expressed as total subframes. - public var maxSubFrameCountExpressible: Int { + public var maxSubFrameCountExpressible: TimecodeTotalCount { frameRate.maxSubFrameCountExpressible( in: upperLimit, base: subFramesBase diff --git a/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift b/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift index dccb0e21..b0adc3e4 100644 --- a/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift +++ b/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift @@ -303,8 +303,8 @@ extension TimecodeFrameRate { public func maxTotalSubFrames( in extent: Timecode.UpperLimit, base: Timecode.SubFramesBase - ) -> Int { - maxTotalFrames(in: extent) * base.rawValue + ) -> TimecodeTotalCount { + TimecodeTotalCount(maxTotalFrames(in: extent)) * TimecodeTotalCount(base.rawValue) } /// Returns max elapsed subframes possible before rolling over to 0. @@ -312,7 +312,7 @@ extension TimecodeFrameRate { public func maxSubFrameCountExpressible( in extent: Timecode.UpperLimit, base: Timecode.SubFramesBase - ) -> Int { + ) -> TimecodeTotalCount { maxTotalSubFrames(in: extent, base: base) - 1 } } diff --git a/Sources/SwiftTimecodeCore/Utilities/TotalCount.swift b/Sources/SwiftTimecodeCore/Utilities/TotalCount.swift new file mode 100644 index 00000000..b176065e --- /dev/null +++ b/Sources/SwiftTimecodeCore/Utilities/TotalCount.swift @@ -0,0 +1,31 @@ +// +// TotalCount.swift +// swift-timecode • https://github.com/orchetect/swift-timecode +// © 2026 Steffan Andrews • Licensed under MIT License +// + +/// Integer type used for **total** counts — total subframes and total audio +/// samples — as opposed to per-component values such as `Components`' hours, +/// minutes, seconds and frames, which remain `Int`. +/// +/// `Int` on 64-bit platforms, so nothing changes for the vast majority of +/// consumers. `Int64` on 32-bit platforms (wasm32, watchOS armv7k/arm64_32), +/// where `Int` cannot represent these values at all: +/// +/// - a `.max100Days` subframe count is at least `2_073_600 * 100 * 80 = +/// 16_588_800_000` for every frame rate at the 80- and 100-subframe bases +/// - an audio sample count is `4_147_200_000` at 24 hours / 48 kHz +/// +/// against an `Int.max` of `2_147_483_647`. Computing either in `Int` traps on +/// overflow, and because the subframe bound is recomputed inside every wrapping +/// add, that took *all* arithmetic on a `.max100Days` timecode with it. +/// +/// Consumers building for both 64- and 32-bit targets from one source can wrap +/// values at the API boundary (`Int64(…)`) or use the same fence. +#if _pointerBitWidth(_64) +public typealias TimecodeTotalCount = Int +#elseif _pointerBitWidth(_32) +public typealias TimecodeTotalCount = Int64 +#else +#error("Unsupported pointer width — TimecodeTotalCount needs a mapping for this platform.") +#endif From f153075c8151ccef1b4210d18bea5ed8ab50c072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Fri, 14 Aug 2026 23:24:25 +0200 Subject: [PATCH 02/12] Adopt PlatformInt at the 32-bit overflow pinch points, including Fraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop-gap for version 3, per review: a platform-conditional alias rather than widening the public API for everyone. #if _pointerBitWidth(_64) public typealias PlatformInt = Int #elseif _pointerBitWidth(_32) public typealias PlatformInt = Int64 #endif On 64-bit the alias IS Int, so the public API is byte-identical and consumers see no change at all — 504 tests pass with zero test-file changes. On 32-bit (wasm32, watchOS armv7k/arm64_32) it becomes Int64 at the points that provably overflow there: - total subframe counts — at `.max100Days` this is >= 16_588_800_000 for every frame rate at the 80/100 subframe bases - total audio sample counts — 4_147_200_000 at 24 hours / 48 kHz, i.e. the library's ORDINARY limits rather than a hypothetical future frame rate - `Fraction`'s numerator and denominator, plus its internal arithmetic Widening `Fraction` removes the last narrowing the earlier attempts had to leave in place: `Timecode.rationalValue` previously squeezed a 64-bit subframe count back into an `Int`, so a timecode beyond ~Int32.max subframes had no representable rational value on a 32-bit platform. That conversion and its apology comment are both gone. Left as `Int` deliberately, because they are components rather than pinch points: `Components`' h/m/s/f, `FrameCount.subFrames`, and `FeetAndFrames`. Total FRAME counts also remain `Int`. They fit today — 120 fps over 100 days is 1_036_800_000, 30 bits — but that is the headroom noted in review: 240 fps is 31 bits and 480 fps overflows. They are a pinch point for a future rate, not a current one, and belong with the major-version pass rather than a stop-gap. --- .../SwiftTimecodeCore/Fraction/Fraction.swift | 34 +++++++++---------- .../Timecode/FrameCount/FrameCount.swift | 18 +++++----- .../Math/Timecode Math Internal.swift | 16 ++++----- .../Timecode/Source/Timecode Rational.swift | 18 ++++------ .../Timecode/Source/Timecode Samples.swift | 8 ++--- .../Timecode/Timecode Validation.swift | 2 +- .../TimecodeFrameRate Properties.swift | 6 ++-- .../{TotalCount.swift => PlatformInt.swift} | 15 ++++---- 8 files changed, 57 insertions(+), 60 deletions(-) rename Sources/SwiftTimecodeCore/Utilities/{TotalCount.swift => PlatformInt.swift} (69%) diff --git a/Sources/SwiftTimecodeCore/Fraction/Fraction.swift b/Sources/SwiftTimecodeCore/Fraction/Fraction.swift index 22d5dd57..d5e738ff 100644 --- a/Sources/SwiftTimecodeCore/Fraction/Fraction.swift +++ b/Sources/SwiftTimecodeCore/Fraction/Fraction.swift @@ -10,8 +10,8 @@ import Foundation /// /// Used to convert to/from ``Timecode``, Core Media `CMTime`, or metadata encoding such as Final Cut Pro XML or AAF. public struct Fraction { - public let numerator: Int - public let denominator: Int + public let numerator: PlatformInt + public let denominator: PlatformInt private let _isReduced: Bool? @@ -40,14 +40,14 @@ public struct Fraction { // MARK: - Init /// Initialize with literal values. - public init(_ numerator: Int, _ denominator: Int) { + public init(_ numerator: PlatformInt, _ denominator: PlatformInt) { self.numerator = numerator self.denominator = denominator _isReduced = nil } /// Initialize by reducing and normalizing the fraction. - public init(reducing numerator: Int, _ denominator: Int) { + public init(reducing numerator: PlatformInt, _ denominator: PlatformInt) { let reduced = reduce(n: numerator, d: denominator) self.numerator = reduced.n self.denominator = reduced.d @@ -66,7 +66,7 @@ public struct Fraction { } /// Internal: Initialize with literal values and force the reduced flag. - init(_ numerator: Int, _ denominator: Int, isReduced: Bool?) { + init(_ numerator: PlatformInt, _ denominator: PlatformInt, isReduced: Bool?) { self.numerator = numerator self.denominator = denominator _isReduced = isReduced @@ -253,10 +253,10 @@ extension Double { // clamp exponent to avoid overflow crashes // Int.max = 9.22... x 10^18 let maxExponent = precision.clamped(to: 0 ... max(19 - integralDigitPlaces, 0)) - let pad = Int(truncating: pow(10, maxExponent) as NSNumber) + let pad = PlatformInt(truncating: pow(10, maxExponent) as NSNumber) let nFloat = absSelf * Double(pad) - let n = Int(truncating: nFloat as NSNumber) + let n = PlatformInt(truncating: nFloat as NSNumber) let d = pad return Fraction(reducing: isNegative ? -n : n, d) @@ -285,7 +285,7 @@ extension Fraction { if wholeSecsMatches.count == 3 { let negativeSign = wholeSecsMatches[1] ?? "" if let secondsString = wholeSecsMatches[2], - let seconds = Int(negativeSign + secondsString) + let seconds = PlatformInt(negativeSign + secondsString) { self.init(seconds, 1, isReduced: true) return @@ -298,9 +298,9 @@ extension Fraction { if fracMatches.count == 4 { let negativeSign = fracMatches[1] ?? "" if let numeratorString = fracMatches[2], - let numerator = Int(negativeSign + numeratorString), + let numerator = PlatformInt(negativeSign + numeratorString), let denominatorString = fracMatches[3], - let denominator = Int(denominatorString) + let denominator = PlatformInt(denominatorString) { self.init(numerator, denominator) return @@ -337,7 +337,7 @@ extension Fraction { /// Normalize a fraction. /// Fractions with two negative signs are normalized to two positive signs. /// Fractions with negative denominator are normalized to negative numerator and positive denominator. -func normalize(n: Int, d: Int) -> (n: Int, d: Int) { +func normalize(n: PlatformInt, d: PlatformInt) -> (n: PlatformInt, d: PlatformInt) { var n = n var d = d if n >= 0 && d >= 0 { return (n: n, d: d) } @@ -351,9 +351,9 @@ func normalize(n: Int, d: Int) -> (n: Int, d: Int) { /// Internal: /// Reduce a fraction to its simplest form. /// This also normalizes signs. -func reduce(n: Int, d: Int) -> (n: Int, d: Int) { - let (absN, signN) = n < 0 ? (-n, -1) : (n, 1) - let (absD, signD) = d < 0 ? (-d, -1) : (d, 1) +func reduce(n: PlatformInt, d: PlatformInt) -> (n: PlatformInt, d: PlatformInt) { + let (absN, signN): (PlatformInt, PlatformInt) = n < 0 ? (-n, -1) : (n, 1) + let (absD, signD): (PlatformInt, PlatformInt) = d < 0 ? (-d, -1) : (d, 1) var v = n var u = d @@ -376,8 +376,8 @@ func reduce(n: Int, d: Int) -> (n: Int, d: Int) { /// Internal: /// Returns greatest common divisor of two numbers. -func greatestCommonDivisor(_ n1: Int, _ n2: Int) -> Int { - var x = 0 +func greatestCommonDivisor(_ n1: PlatformInt, _ n2: PlatformInt) -> PlatformInt { + var x: PlatformInt = 0 var y = max(n1, n2) var z = min(n1, n2) @@ -392,7 +392,7 @@ func greatestCommonDivisor(_ n1: Int, _ n2: Int) -> Int { /// Internal: /// Returns least common multiple of two numbers and their respective multipliers. -func leastCommonMultiple(lhs: Int, rhs: Int) -> (denominator: Int, lhsMultiplier: Int, rhsMultiplier: Int) { +func leastCommonMultiple(lhs: PlatformInt, rhs: PlatformInt) -> (denominator: PlatformInt, lhsMultiplier: PlatformInt, rhsMultiplier: PlatformInt) { let denominator = lhs * rhs / greatestCommonDivisor(lhs, rhs) let lhsMultiplier = denominator / lhs let rhsMultiplier = denominator / rhs diff --git a/Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift b/Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift index 3db232d3..3d5bfa73 100644 --- a/Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift +++ b/Sources/SwiftTimecodeCore/Timecode/FrameCount/FrameCount.swift @@ -105,7 +105,7 @@ extension Timecode { extension Timecode.FrameCount { init( - subFrameCount: TimecodeTotalCount, + subFrameCount: PlatformInt, base: Timecode.SubFramesBase ) { let converted = Timecode.subFramesToFrames( @@ -225,7 +225,7 @@ extension Timecode.FrameCount { public func multiplying(by factor: Double) -> Self { let lhsTotalSubFrames = subFrameCount - let resultSubFrameCount = TimecodeTotalCount(Double(lhsTotalSubFrames) * factor) + let resultSubFrameCount = PlatformInt(Double(lhsTotalSubFrames) * factor) let newFrames = Timecode.subFramesToFrames( resultSubFrameCount, @@ -241,7 +241,7 @@ extension Timecode.FrameCount { public func dividing(by divisor: Double) -> Self { let lhsTotalSubFrames = subFrameCount - let resultSubFrameCount = TimecodeTotalCount(Double(lhsTotalSubFrames) / divisor) + let resultSubFrameCount = PlatformInt(Double(lhsTotalSubFrames) / divisor) let newFrames = Timecode.subFramesToFrames( resultSubFrameCount, @@ -268,7 +268,7 @@ extension Timecode.FrameCount { } extension Timecode.FrameCount { - var subFrameCount: TimecodeTotalCount { + var subFrameCount: PlatformInt { Timecode.framesToSubFrames( frames: wholeFrames, subFrames: subFrames, @@ -285,14 +285,14 @@ extension Timecode { frames: Int, subFrames: Int, base: SubFramesBase - ) -> TimecodeTotalCount { - (TimecodeTotalCount(frames) * TimecodeTotalCount(base.rawValue)) + TimecodeTotalCount(subFrames) + ) -> PlatformInt { + (PlatformInt(frames) * PlatformInt(base.rawValue)) + PlatformInt(subFrames) } /// Internal utility - static func subFramesToFrames(_ subFrames: TimecodeTotalCount, base: SubFramesBase) -> (frames: Int, subFrames: Int) { - let outSubFrames = subFrames % TimecodeTotalCount(base.rawValue) - let outFrames = (subFrames - outSubFrames) / TimecodeTotalCount(base.rawValue) + static func subFramesToFrames(_ subFrames: PlatformInt, base: SubFramesBase) -> (frames: Int, subFrames: Int) { + let outSubFrames = subFrames % PlatformInt(base.rawValue) + let outFrames = (subFrames - outSubFrames) / PlatformInt(base.rawValue) return (frames: Int(outFrames), subFrames: Int(outSubFrames)) } diff --git a/Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift b/Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift index a7cc2cbb..ac9568ed 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Math/Timecode Math Internal.swift @@ -311,7 +311,7 @@ extension Timecode { if sfcNew > Double(maxSubFrameCountExpressible) { return nil } let fcNew = FrameCount( - subFrameCount: TimecodeTotalCount(sfcNew), + subFrameCount: PlatformInt(sfcNew), base: subFramesBase ) @@ -333,7 +333,7 @@ extension Timecode { base: subFramesBase ) - var sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) * factor) + var sfcNew = PlatformInt(Double(fcOrigin.subFrameCount) * factor) sfcNew = sfcNew.clamped(to: 0 ... maxSubFrameCountExpressible) @@ -360,7 +360,7 @@ extension Timecode { base: subFramesBase ) - var sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) * factor) + var sfcNew = PlatformInt(Double(fcOrigin.subFrameCount) * factor) let maxTotalSubFrames = frameRate.maxTotalSubFrames( in: upperLimit, @@ -401,7 +401,7 @@ extension Timecode { base: subFramesBase ) - let sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) * factor) + let sfcNew = PlatformInt(Double(fcOrigin.subFrameCount) * factor) let fcNew = FrameCount( subFrameCount: sfcNew, @@ -434,7 +434,7 @@ extension Timecode { if sfcNew > Double(maxSubFrameCountExpressible) { return nil } let fcNew = FrameCount( - subFrameCount: TimecodeTotalCount(sfcNew), + subFrameCount: PlatformInt(sfcNew), base: subFramesBase ) @@ -456,7 +456,7 @@ extension Timecode { base: subFramesBase ) - var sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) / divisor) + var sfcNew = PlatformInt(Double(fcOrigin.subFrameCount) / divisor) sfcNew = sfcNew.clamped(to: 0 ... maxSubFrameCountExpressible) @@ -483,7 +483,7 @@ extension Timecode { base: subFramesBase ) - var sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) / divisor) + var sfcNew = PlatformInt(Double(fcOrigin.subFrameCount) / divisor) let maxTotalSubFrames = frameRate.maxTotalSubFrames( in: upperLimit, @@ -524,7 +524,7 @@ extension Timecode { base: subFramesBase ) - let sfcNew = TimecodeTotalCount(Double(fcOrigin.subFrameCount) / divisor) + let sfcNew = PlatformInt(Double(fcOrigin.subFrameCount) / divisor) let fcNew = FrameCount( subFrameCount: sfcNew, diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift index c27d5665..3c7b299e 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational.swift @@ -34,7 +34,7 @@ extension TimecodeSourceValue { } /// Numerical fraction containing a numerator and a denominator. - public static func rational(_ numerator: Int, _ denominator: Int) -> Self { + public static func rational(_ numerator: PlatformInt, _ denominator: PlatformInt) -> Self { .init(value: Fraction(numerator, denominator)) } } @@ -50,12 +50,8 @@ extension Timecode { /// fractions.) public var rationalValue: Fraction { let frFrac = frameRate.frameDuration - // `Fraction` is Int-based. On a 32-bit platform a timecode beyond - // ~Int32.max subframes therefore has no representable rational value — - // a limit of `Fraction`, not of the count. Widening `Fraction` belongs - // with the broader consistency work rather than with this fix. - let n = frFrac.numerator * Int(clamping: frameCount.subFrameCount) - let d = frFrac.denominator * subFramesBase.rawValue + let n = frFrac.numerator * frameCount.subFrameCount + let d = frFrac.denominator * PlatformInt(subFramesBase.rawValue) return Fraction(n, d).reduced() } @@ -89,7 +85,7 @@ extension Timecode { /// fractions.) mutating func _setTimecode(clamping rational: Fraction) { let frameCount = frameCount(of: rational) - _setTimecode(clamping: .frames(frameCount)) + _setTimecode(clamping: .frames(Int(clamping: frameCount))) } /// Sets the timecode from elapsed time expressed as a rational fraction. @@ -102,7 +98,7 @@ extension Timecode { /// fractions.) mutating func _setTimecode(wrapping rational: Fraction) { let frameCount = frameCount(of: rational) - _setTimecode(wrapping: .frames(frameCount)) + _setTimecode(wrapping: .frames(Int(clamping: frameCount))) } /// Sets the timecode from elapsed time expressed as a rational fraction. @@ -115,7 +111,7 @@ extension Timecode { /// fractions.) mutating func _setTimecode(rawValues rational: Fraction) { let frameCount = frameCount(of: rational) - _setTimecode(rawValues: .frames(frameCount)) + _setTimecode(rawValues: .frames(Int(clamping: frameCount))) } // MARK: Helper Methods @@ -123,7 +119,7 @@ extension Timecode { /// Internal: /// Returns frame count of the rational fraction at current frame rate. /// Truncates subframes if present. - func frameCount(of rational: Fraction) -> Int { + func frameCount(of rational: Fraction) -> PlatformInt { let frFrac = frameRate.frameDuration let frameCount = (rational.numerator * frFrac.denominator) / (rational.denominator * frFrac.numerator) diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift index ee58c38f..fa185ebf 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift @@ -34,7 +34,7 @@ extension SamplesPayload: _TimecodeSource { extension TimecodeSourceValue { /// Audio samples at a given sample rate. - public static func samples(_ samples: TimecodeTotalCount, sampleRate: Int) -> Self { + public static func samples(_ samples: PlatformInt, sampleRate: Int) -> Self { .init(value: SamplesPayload(samples: Double(samples), sampleRate: sampleRate)) } @@ -51,14 +51,14 @@ extension Timecode { /// Returns the current timecode converted to a duration in audio samples /// at the given sample rate, rounded to the nearest sample. /// Sample rate is expressed in Hz. (ie: 48KHz would be passed as 48000) - public func samplesValue(sampleRate: Int) -> TimecodeTotalCount { + public func samplesValue(sampleRate: Int) -> PlatformInt { let val = samplesDoubleValue(sampleRate: sampleRate).rounded() // avoid crash if Double is too big - guard val <= Double(TimecodeTotalCount.max) else { + guard val <= Double(PlatformInt.max) else { // assertionFailure("Timecode is too large to convert to audio samples. This will fail silently in a release build.") return 0 } - return TimecodeTotalCount(val) + return PlatformInt(val) } /// (Lossy) diff --git a/Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift b/Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift index 08061fd7..a7f3bb2b 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Timecode Validation.swift @@ -197,7 +197,7 @@ extension Timecode { } /// Returns the `upperLimit` minus 1 subframe expressed as total subframes. - public var maxSubFrameCountExpressible: TimecodeTotalCount { + public var maxSubFrameCountExpressible: PlatformInt { frameRate.maxSubFrameCountExpressible( in: upperLimit, base: subFramesBase diff --git a/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift b/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift index b0adc3e4..5f186df3 100644 --- a/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift +++ b/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Properties.swift @@ -303,8 +303,8 @@ extension TimecodeFrameRate { public func maxTotalSubFrames( in extent: Timecode.UpperLimit, base: Timecode.SubFramesBase - ) -> TimecodeTotalCount { - TimecodeTotalCount(maxTotalFrames(in: extent)) * TimecodeTotalCount(base.rawValue) + ) -> PlatformInt { + PlatformInt(maxTotalFrames(in: extent)) * PlatformInt(base.rawValue) } /// Returns max elapsed subframes possible before rolling over to 0. @@ -312,7 +312,7 @@ extension TimecodeFrameRate { public func maxSubFrameCountExpressible( in extent: Timecode.UpperLimit, base: Timecode.SubFramesBase - ) -> TimecodeTotalCount { + ) -> PlatformInt { maxTotalSubFrames(in: extent, base: base) - 1 } } diff --git a/Sources/SwiftTimecodeCore/Utilities/TotalCount.swift b/Sources/SwiftTimecodeCore/Utilities/PlatformInt.swift similarity index 69% rename from Sources/SwiftTimecodeCore/Utilities/TotalCount.swift rename to Sources/SwiftTimecodeCore/Utilities/PlatformInt.swift index b176065e..45ddc5c3 100644 --- a/Sources/SwiftTimecodeCore/Utilities/TotalCount.swift +++ b/Sources/SwiftTimecodeCore/Utilities/PlatformInt.swift @@ -1,12 +1,13 @@ // -// TotalCount.swift +// PlatformInt.swift // swift-timecode • https://github.com/orchetect/swift-timecode // © 2026 Steffan Andrews • Licensed under MIT License // -/// Integer type used for **total** counts — total subframes and total audio -/// samples — as opposed to per-component values such as `Components`' hours, -/// minutes, seconds and frames, which remain `Int`. +/// Integer type used at the library's overflow pinch points — total subframe +/// counts, total audio sample counts, and `Fraction`'s terms — as opposed to +/// per-component values such as `Components`' hours, minutes, seconds and +/// frames, which remain `Int`. /// /// `Int` on 64-bit platforms, so nothing changes for the vast majority of /// consumers. `Int64` on 32-bit platforms (wasm32, watchOS armv7k/arm64_32), @@ -23,9 +24,9 @@ /// Consumers building for both 64- and 32-bit targets from one source can wrap /// values at the API boundary (`Int64(…)`) or use the same fence. #if _pointerBitWidth(_64) -public typealias TimecodeTotalCount = Int +public typealias PlatformInt = Int #elseif _pointerBitWidth(_32) -public typealias TimecodeTotalCount = Int64 +public typealias PlatformInt = Int64 #else -#error("Unsupported pointer width — TimecodeTotalCount needs a mapping for this platform.") +#error("Unsupported pointer width — PlatformInt needs a mapping for this platform.") #endif From 2f020dc1bd47872dd447d029dd9e3f749735911b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Sat, 15 Aug 2026 00:03:56 +0200 Subject: [PATCH 03/12] Un-fence the 32-bit-exempt test, and fence the Int companion it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the review point that some tests were fenced off from 32-bit entirely, so a green WASM run does not prove much on its own. That was right, and the one fence in the suite was the worst possible one to have: // these integers result in overflow on armv7/i386 (32-bit arch) #if !(arch(arm) || arch(i386)) #expect(frameRate.maxTotalSubFrames(in: .max100Days, base: .max80SubFrames) == 2_592_000 * 100 * 80) That is the assertion for the exact bound that traps on 32-bit — fenced off from the only platforms that had the bug. Typing the expected value to `PlatformInt` lets it run everywhere; on 64-bit the arithmetic is unchanged. `Tests/` now has zero architecture fences. Also adds an `Int` companion to `.samples(_:sampleRate:)`, which the alias turns out to REQUIRE on 32-bit: with only `Int64` and `Double` overloads visible, an ordinary literal expression like `.samples(48000 * 2, sampleRate: 48000)` is ambiguous, because `Int` is Swift's default integer-literal type and matches neither exactly. Without it the alias would silently stop ordinary call sites compiling on exactly the platforms it exists to serve. The companion itself must be fenced to `_pointerBitWidth(_32)`: on 64-bit `PlatformInt` IS `Int`, so an unconditional companion is an `invalid redeclaration`. Any overload added alongside an aliased one carries the same constraint — worth knowing before this pattern spreads. Consequence, stated plainly: on 32-bit, a large literal now resolves to the `Int` companion and needs explicit typing (`4_147_200_000 as PlatformInt`). That is a compile error rather than a silent truncation, and the sample tests are typed accordingly. Native: 504 tests in 55 suites. --- .../Timecode/Source/Timecode Samples.swift | 22 +++++++++++++++++++ .../Source/Timecode Samples Tests.swift | 20 ++++++++--------- .../TimecodeFrameRate Properties Tests.swift | 16 +++++++++----- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift index fa185ebf..8f2d5d40 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift @@ -38,6 +38,28 @@ extension TimecodeSourceValue { .init(value: SamplesPayload(samples: Double(samples), sampleRate: sampleRate)) } + /// Audio samples at a given sample rate. + /// + /// `Int` companion to the `PlatformInt` overload. Required, not redundant: + /// on a 32-bit platform `PlatformInt` is `Int64`, and with only `Int64` and + /// `Double` overloads present an ordinary literal expression such as + /// `.samples(48000 * 2, sampleRate: 48000)` becomes AMBIGUOUS, because `Int` + /// is Swift's default integer-literal type and matches neither exactly. + /// Without this, the alias would silently make ordinary call sites stop + /// compiling on exactly the platforms it exists to support. + /// + /// Not deprecated, for the same reason — a deprecated overload would warn on + /// ordinary literal use. + /// + /// FENCED, and it has to be: on a 64-bit platform `PlatformInt` *is* `Int`, + /// so an unconditional companion here is an `invalid redeclaration`. Any + /// overload added alongside an aliased one must carry the same fence. + #if _pointerBitWidth(_32) + public static func samples(_ samples: Int, sampleRate: Int) -> Self { + .init(value: SamplesPayload(samples: Double(samples), sampleRate: sampleRate)) + } + #endif + /// Audio samples at a given sample rate. public static func samples(_ samples: Double, sampleRate: Int) -> Self { .init(value: SamplesPayload(samples: samples, sampleRate: sampleRate)) diff --git a/Tests/SwiftTimecodeCoreTests/Timecode/Source/Timecode Samples Tests.swift b/Tests/SwiftTimecodeCoreTests/Timecode/Source/Timecode Samples Tests.swift index cc645c4a..7ba5fee6 100644 --- a/Tests/SwiftTimecodeCoreTests/Timecode/Source/Timecode Samples Tests.swift +++ b/Tests/SwiftTimecodeCoreTests/Timecode/Source/Timecode Samples Tests.swift @@ -25,7 +25,7 @@ struct Timecode_Source_Samples_Tests { func timecode_init_Samples_Clamping() { let tc = Timecode( .samples( - 4_147_200_000 + 172_800_000, // 25 hours @ 24fps + 4_147_200_000 as PlatformInt + 172_800_000, // 25 hours @ 24fps sampleRate: 48000 ), at: .fps24, @@ -42,7 +42,7 @@ struct Timecode_Source_Samples_Tests { func timecode_init_Samples_Wrapping() { let tc = Timecode( .samples( - 4_147_200_000 + 172_800_000, // 25 hours @ 24fps + 4_147_200_000 as PlatformInt + 172_800_000, // 25 hours @ 24fps sampleRate: 48000 ), at: .fps24, @@ -56,7 +56,7 @@ struct Timecode_Source_Samples_Tests { func timecode_init_Samples_RawValues() { let tc = Timecode( .samples( - (4_147_200_000 * 2) + 172_800_000, // 2 days + 1 hour @ 24fps + (4_147_200_000 as PlatformInt * 2) + 172_800_000, // 2 days + 1 hour @ 24fps sampleRate: 48000 ), at: .fps24, @@ -70,7 +70,7 @@ struct Timecode_Source_Samples_Tests { func timecode_init_Samples_RawValues_Negative() { let tc = Timecode( .samples( - -((4_147_200_000 * 2) + 172_800_000), // 2 days + 1 hour @ 24fps + -((4_147_200_000 as PlatformInt * 2) + 172_800_000), // 2 days + 1 hour @ 24fps sampleRate: 48000 ), at: .fps24, @@ -122,7 +122,7 @@ struct Timecode_Source_Samples_Tests { // MARK: samples as Int func validate( - using samplesIn1DayTC: Int, + using samplesIn1DayTC: PlatformInt, sRate: Int, fRate: TimecodeFrameRate ) throws { @@ -142,7 +142,7 @@ struct Timecode_Source_Samples_Tests { let sRate = 48000 var samplesIn1DayTCDouble = 0.0 - var samplesIn1DayTCInt = 0 + var samplesIn1DayTCInt: PlatformInt = 0 var roundedForDropFrame = false switch frameRate { @@ -154,7 +154,7 @@ struct Timecode_Source_Samples_Tests { .fps95_904, .fps119_88: samplesIn1DayTCDouble = samplesIn1DayTC_ShrunkFrameRates - samplesIn1DayTCInt = Int(samplesIn1DayTCDouble) + samplesIn1DayTCInt = PlatformInt(samplesIn1DayTCDouble) roundedForDropFrame = false case .fps24, @@ -168,7 +168,7 @@ struct Timecode_Source_Samples_Tests { .fps100, .fps120: samplesIn1DayTCDouble = samplesIn1DayTC_BaseFrameRates - samplesIn1DayTCInt = Int(samplesIn1DayTCDouble) + samplesIn1DayTCInt = PlatformInt(samplesIn1DayTCDouble) roundedForDropFrame = false case .fps29_97d, @@ -182,14 +182,14 @@ struct Timecode_Source_Samples_Tests { // - double this would technically be 4147195854 but Cubase shows 1 frame less samplesIn1DayTCDouble = samplesIn1DayTC_DropFrameRates - samplesIn1DayTCInt = Int(samplesIn1DayTCDouble) + samplesIn1DayTCInt = PlatformInt(samplesIn1DayTCDouble) roundedForDropFrame = true // DAWs seem to using standard rounding for DF (?) case .fps30d, .fps60d, .fps120d: samplesIn1DayTCDouble = samplesIn1DayTC_30DF - samplesIn1DayTCInt = Int(samplesIn1DayTCDouble) + samplesIn1DayTCInt = PlatformInt(samplesIn1DayTCDouble) roundedForDropFrame = false } diff --git a/Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift b/Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift index dcb90e02..e9e0ad19 100644 --- a/Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift +++ b/Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift @@ -53,14 +53,21 @@ struct TimecodeFrameRate_Properties_Tests { == 2_592_000 * 80 ) - // these integers result in overflow on armv7/i386 (32-bit arch) - #if !(arch(arm) || arch(i386)) + // Previously fenced off from armv7/i386 because these literals overflow a + // 32-bit `Int`. They no longer need to be: `PlatformInt` is `Int64` on + // 32-bit platforms, so typing the expected value to it lets the assertion + // run everywhere. On 64-bit the arithmetic is unchanged — `PlatformInt` + // IS `Int` there. + // + // Worth un-fencing rather than leaving: this is the assertion for the + // exact bound that used to trap on 32-bit, so fencing it hid the bug from + // the only platform that had it. #expect( frameRate.maxTotalSubFrames( in: .max100Days, base: .max80SubFrames ) - == 2_592_000 * 100 * 80 + == PlatformInt(2_592_000) * 100 * 80 ) #expect( @@ -68,9 +75,8 @@ struct TimecodeFrameRate_Properties_Tests { in: .max100Days, base: .max80SubFrames ) - == (2_592_000 * 100 * 80) - 1 + == (PlatformInt(2_592_000) * 100 * 80) - 1 ) - #endif #expect( frameRate.maxSubFrameCountExpressible( From 5272c504ba2bc87dd915cb35a7623ae63210e0bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Bernhardt?= Date: Sat, 15 Aug 2026 00:46:24 +0200 Subject: [PATCH 04/12] Use overload precedence instead of an Int companion for .samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fenced `Int` companion with `@_disfavoredOverload` on the `Double` overload, per review. Strictly better on every axis: - an integer literal resolves to `PlatformInt` instead of being ambiguous - a full 64-bit literal compiles UNANNOTATED on 32-bit — the explicit `4_147_200_000 as PlatformInt` typing the companion forced is reverted - no `#if _pointerBitWidth(_32)` fence around an overload, so the pattern does not have to spread - and the reason that matters most: the companion silently narrowed to `Int`, which hid from the consumer that a 64-bit value was required at all. Overload precedence surfaces the requirement instead of papering over it. Verified: 504 native tests; on wasm32 the test target builds with ZERO errors beyond the two pre-existing classes unrelated to this PR (the `@Test`/`@Suite` `@section`/`@const` macro errors, and the oversized numeric-string literals in the string-parsing and FeetAndFrames tests, both present on a pristine `main`). --- .../Timecode/Source/Timecode Samples.swift | 26 +++++-------------- .../Source/Timecode Samples Tests.swift | 8 +++--- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift index 8f2d5d40..90fe82a2 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift @@ -40,27 +40,15 @@ extension TimecodeSourceValue { /// Audio samples at a given sample rate. /// - /// `Int` companion to the `PlatformInt` overload. Required, not redundant: - /// on a 32-bit platform `PlatformInt` is `Int64`, and with only `Int64` and - /// `Double` overloads present an ordinary literal expression such as - /// `.samples(48000 * 2, sampleRate: 48000)` becomes AMBIGUOUS, because `Int` - /// is Swift's default integer-literal type and matches neither exactly. - /// Without this, the alias would silently make ordinary call sites stop - /// compiling on exactly the platforms it exists to support. - /// - /// Not deprecated, for the same reason — a deprecated overload would warn on - /// ordinary literal use. - /// - /// FENCED, and it has to be: on a 64-bit platform `PlatformInt` *is* `Int`, - /// so an unconditional companion here is an `invalid redeclaration`. Any - /// overload added alongside an aliased one must carry the same fence. - #if _pointerBitWidth(_32) - public static func samples(_ samples: Int, sampleRate: Int) -> Self { - .init(value: SamplesPayload(samples: Double(samples), sampleRate: sampleRate)) - } - #endif /// Audio samples at a given sample rate. + /// + /// `@_disfavoredOverload` so an integer literal resolves to the + /// `PlatformInt` overload rather than becoming ambiguous between the two. + /// This is what lets `.samples(48000 * 2, sampleRate: 48000)` and a full + /// 64-bit literal both compile unannotated on a 32-bit platform, without an + /// `Int` companion that would silently narrow and hide the requirement. + @_disfavoredOverload public static func samples(_ samples: Double, sampleRate: Int) -> Self { .init(value: SamplesPayload(samples: samples, sampleRate: sampleRate)) } diff --git a/Tests/SwiftTimecodeCoreTests/Timecode/Source/Timecode Samples Tests.swift b/Tests/SwiftTimecodeCoreTests/Timecode/Source/Timecode Samples Tests.swift index 7ba5fee6..1ad9a383 100644 --- a/Tests/SwiftTimecodeCoreTests/Timecode/Source/Timecode Samples Tests.swift +++ b/Tests/SwiftTimecodeCoreTests/Timecode/Source/Timecode Samples Tests.swift @@ -25,7 +25,7 @@ struct Timecode_Source_Samples_Tests { func timecode_init_Samples_Clamping() { let tc = Timecode( .samples( - 4_147_200_000 as PlatformInt + 172_800_000, // 25 hours @ 24fps + 4_147_200_000 + 172_800_000, // 25 hours @ 24fps sampleRate: 48000 ), at: .fps24, @@ -42,7 +42,7 @@ struct Timecode_Source_Samples_Tests { func timecode_init_Samples_Wrapping() { let tc = Timecode( .samples( - 4_147_200_000 as PlatformInt + 172_800_000, // 25 hours @ 24fps + 4_147_200_000 + 172_800_000, // 25 hours @ 24fps sampleRate: 48000 ), at: .fps24, @@ -56,7 +56,7 @@ struct Timecode_Source_Samples_Tests { func timecode_init_Samples_RawValues() { let tc = Timecode( .samples( - (4_147_200_000 as PlatformInt * 2) + 172_800_000, // 2 days + 1 hour @ 24fps + (4_147_200_000 * 2) + 172_800_000, // 2 days + 1 hour @ 24fps sampleRate: 48000 ), at: .fps24, @@ -70,7 +70,7 @@ struct Timecode_Source_Samples_Tests { func timecode_init_Samples_RawValues_Negative() { let tc = Timecode( .samples( - -((4_147_200_000 as PlatformInt * 2) + 172_800_000), // 2 days + 1 hour @ 24fps + -((4_147_200_000 * 2) + 172_800_000), // 2 days + 1 hour @ 24fps sampleRate: 48000 ), at: .fps24, From c87c64e34fcf810d0eb36617ae0411c04937c359 Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Fri, 14 Aug 2026 16:00:38 -0700 Subject: [PATCH 05/12] `Fraction`: `init(_ cmTime: CMTime)`: Replaced `Int` cast with `PlatformInt` --- Sources/SwiftTimecodeCore/Fraction/Fraction CMTime.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftTimecodeCore/Fraction/Fraction CMTime.swift b/Sources/SwiftTimecodeCore/Fraction/Fraction CMTime.swift index 165506fa..dcd76852 100644 --- a/Sources/SwiftTimecodeCore/Fraction/Fraction CMTime.swift +++ b/Sources/SwiftTimecodeCore/Fraction/Fraction CMTime.swift @@ -20,7 +20,7 @@ extension Fraction { return } - self.init(Int(cmTime.value), Int(cmTime.timescale)) + self.init(PlatformInt(cmTime.value), PlatformInt(cmTime.timescale)) } /// Returns the fraction as a new `CMTime` instance. From ab6436184110506426625c10b166025b05af96ac Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Fri, 14 Aug 2026 16:08:18 -0700 Subject: [PATCH 06/12] Reworded comment --- .../Timecode/Source/Timecode Samples.swift | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift index 90fe82a2..3fe47899 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Samples.swift @@ -38,16 +38,8 @@ extension TimecodeSourceValue { .init(value: SamplesPayload(samples: Double(samples), sampleRate: sampleRate)) } + // Disfavored overload avoids type ambiguity at call-site for integer literals on 32-bit platforms. /// Audio samples at a given sample rate. - /// - - /// Audio samples at a given sample rate. - /// - /// `@_disfavoredOverload` so an integer literal resolves to the - /// `PlatformInt` overload rather than becoming ambiguous between the two. - /// This is what lets `.samples(48000 * 2, sampleRate: 48000)` and a full - /// 64-bit literal both compile unannotated on a 32-bit platform, without an - /// `Int` companion that would silently narrow and hide the requirement. @_disfavoredOverload public static func samples(_ samples: Double, sampleRate: Int) -> Self { .init(value: SamplesPayload(samples: samples, sampleRate: sampleRate)) From 090a70a8c45a46121e71e52cce3675e8114a055d Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Fri, 14 Aug 2026 16:24:19 -0700 Subject: [PATCH 07/12] `PlatformInt`: Updated inline documentation copy --- .../Utilities/PlatformInt.swift | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/Sources/SwiftTimecodeCore/Utilities/PlatformInt.swift b/Sources/SwiftTimecodeCore/Utilities/PlatformInt.swift index 45ddc5c3..cd4d5494 100644 --- a/Sources/SwiftTimecodeCore/Utilities/PlatformInt.swift +++ b/Sources/SwiftTimecodeCore/Utilities/PlatformInt.swift @@ -4,29 +4,48 @@ // © 2026 Steffan Andrews • Licensed under MIT License // -/// Integer type used at the library's overflow pinch points — total subframe -/// counts, total audio sample counts, and `Fraction`'s terms — as opposed to -/// per-component values such as `Components`' hours, minutes, seconds and -/// frames, which remain `Int`. -/// -/// `Int` on 64-bit platforms, so nothing changes for the vast majority of -/// consumers. `Int64` on 32-bit platforms (wasm32, watchOS armv7k/arm64_32), -/// where `Int` cannot represent these values at all: -/// -/// - a `.max100Days` subframe count is at least `2_073_600 * 100 * 80 = -/// 16_588_800_000` for every frame rate at the 80- and 100-subframe bases -/// - an audio sample count is `4_147_200_000` at 24 hours / 48 kHz +#if _pointerBitWidth(_64) || _pointerBitWidth(_128) + +/// Signed integer type that aliases to the appropriate concrete type for the target platform. +/// Resolves to `Int` on 64-bit platforms and `Int64` on 32-bit platforms (including `wasm32`, +/// `armv7k`, and `arm64_32`). /// -/// against an `Int.max` of `2_147_483_647`. Computing either in `Int` traps on -/// overflow, and because the subframe bound is recomputed inside every wrapping -/// add, that took *all* arithmetic on a `.max100Days` timecode with it. +/// > Tip: +/// > +/// > Consumers building for 64-bit platforms only may use `Int` at call-sites without any special +/// > accommodations. Consumers for 32-bit platforms or consumers building cross-platform modules +/// > for both 64- and 32-bit targets from one source should wrap values at call-sites with `Int64`. /// -/// Consumers building for both 64- and 32-bit targets from one source can wrap -/// values at the API boundary (`Int64(…)`) or use the same fence. -#if _pointerBitWidth(_64) +/// > Note: +/// > +/// > This type is used only as a retroactive solution for providing cross-platform compatibility to +/// > this library in a non-breaking way for existing 64-bit bit platform consumers while adding +/// > support for 32-bit platforms with little to no compromises. A future version of this library +/// > may remove this type alias and adopt specific bit-width integer types that are stable across +/// > all architectures. public typealias PlatformInt = Int + #elseif _pointerBitWidth(_32) + +/// Signed integer type that aliases to the appropriate concrete type for the target platform. +/// Resolves to `Int` on 64-bit platforms and `Int64` on 32-bit platforms (including `wasm32`, +/// `armv7k`, and `arm64_32`). +/// +/// > Tip: +/// > +/// > Consumers building for 64-bit platforms only may use `Int` at call-sites without any special +/// > accommodations. Consumers for 32-bit platforms or consumers building cross-platform modules +/// > for both 64- and 32-bit targets from one source should wrap values at call-sites with `Int64`. +/// +/// > Note: +/// > +/// > This type is used only as a retroactive solution for providing cross-platform compatibility to +/// > this library in a non-breaking way for existing 64-bit bit platform consumers while adding +/// > support for 32-bit platforms with little to no compromises. A future version of this library +/// > may remove this type alias and adopt specific bit-width integer types that are stable across +/// > all architectures. public typealias PlatformInt = Int64 + #else #error("Unsupported pointer width — PlatformInt needs a mapping for this platform.") #endif From f71abd87c36b088a4401ae251b8781620d03f791 Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Fri, 14 Aug 2026 16:47:53 -0700 Subject: [PATCH 08/12] Added `PlatformInt` to docs --- Sources/SwiftTimecodeCore/Documentation.docc/Documentation.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/SwiftTimecodeCore/Documentation.docc/Documentation.md b/Sources/SwiftTimecodeCore/Documentation.docc/Documentation.md index 3a80884c..5cd16f91 100644 --- a/Sources/SwiftTimecodeCore/Documentation.docc/Documentation.md +++ b/Sources/SwiftTimecodeCore/Documentation.docc/Documentation.md @@ -42,3 +42,7 @@ Value types and related logic for representing and working with SMPTE/EBU timeco ### Additional Value Types - ``FeetAndFrames`` + +### Cross-Platform Types + +- ``PlatformInt`` From f68277eddc15104244830e68640187d4d486ba43 Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Fri, 14 Aug 2026 16:48:17 -0700 Subject: [PATCH 09/12] Fixed broken links in docs --- .../Documentation.docc/Documentation.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftTimecodeUI/Documentation.docc/Documentation.md b/Sources/SwiftTimecodeUI/Documentation.docc/Documentation.md index 41fbe8cf..88c40169 100644 --- a/Sources/SwiftTimecodeUI/Documentation.docc/Documentation.md +++ b/Sources/SwiftTimecodeUI/Documentation.docc/Documentation.md @@ -19,23 +19,23 @@ UI controls and tools for formatting and displaying timecode, including user-edi ### SwiftUI View Modifiers - ``SwiftUICore/View/timecodeFormat(_:)`` -- ``SwiftUICore/View/timecodeFieldHighlightStyle(_:)-(S)`` -- ``SwiftUICore/View/timecodeFieldHighlightStyle(_:)-(S?)`` +- ``SwiftUICore/View/timecodeFieldHighlightStyle(_:)-(ShapeStyle)`` +- ``SwiftUICore/View/timecodeFieldHighlightStyle(_:)-8k1sm`` - ``SwiftUICore/View/timecodeFieldInputStyle(_:)`` - ``SwiftUICore/View/timecodeFieldInputWrapping(_:)`` - ``SwiftUICore/View/timecodeFieldEscapeAction(_:)`` - ``SwiftUICore/View/timecodeFieldReturnAction(_:)`` - ``SwiftUICore/View/timecodeFieldValidationPolicy(_:)`` - ``SwiftUICore/View/timecodeFieldInputRejectionFeedback(_:)`` -- ``SwiftUICore/View/timecodeSeparatorStyle(_:)-(S)`` -- ``SwiftUICore/View/timecodeSeparatorStyle(_:)-(S?)`` -- ``SwiftUICore/View/timecodeSubFramesStyle(_:)-(S)`` -- ``SwiftUICore/View/timecodeSubFramesStyle(_:)-(S?)`` +- ``SwiftUICore/View/timecodeSeparatorStyle(_:)-(ShapeStyle)`` +- ``SwiftUICore/View/timecodeSeparatorStyle(_:)-5cwmi`` +- ``SwiftUICore/View/timecodeSubFramesStyle(_:)-(ShapeStyle)`` +- ``SwiftUICore/View/timecodeSubFramesStyle(_:)-4p9lf`` - ``SwiftUICore/View/timecodeSubFramesStyle(scale:)`` -- ``SwiftUICore/View/timecodeSubFramesStyle(_:scale:)-(S,_)`` -- ``SwiftUICore/View/timecodeSubFramesStyle(_:scale:)-(S?,_)`` -- ``SwiftUICore/View/timecodeValidationStyle(_:)-(S)`` -- ``SwiftUICore/View/timecodeValidationStyle(_:)-(S?)`` +- ``SwiftUICore/View/timecodeSubFramesStyle(_:scale:)-(ShapeStyle,_)`` +- ``SwiftUICore/View/timecodeSubFramesStyle(_:scale:)-7fidz`` +- ``SwiftUICore/View/timecodeValidationStyle(_:)-(ShapeStyle)`` +- ``SwiftUICore/View/timecodeValidationStyle(_:)-2me4o`` ### SwiftUI State From 9a969a327133d783962e3850462f89beb7185dde Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Fri, 14 Aug 2026 16:54:52 -0700 Subject: [PATCH 10/12] Respelled `Int` casts as `PlatformInt` when constructing `Fraction` --- .../Timecode/Source/Timecode Rational CMTime.swift | 8 ++++---- .../TimecodeFrameRate/TimecodeFrameRate Conversions.swift | 4 ++-- .../VideoFrameRate/VideoFrameRate Conversions.swift | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational CMTime.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational CMTime.swift index 2de4bb3d..785ba4a7 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational CMTime.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational CMTime.swift @@ -81,7 +81,7 @@ extension Timecode { /// /// - Throws: ``ValidationError`` mutating func _setTimecode(exactly: CMTime) throws { - let fraction = Fraction(Int(exactly.value), Int(exactly.timescale)) + let fraction = Fraction(PlatformInt(exactly.value), Int(exactly.timescale)) try _setTimecode(exactly: fraction) } @@ -92,7 +92,7 @@ extension Timecode { /// - Note: Many AVFoundation and Core Media objects utilize `CMTime` as a way to represent /// times and durations. mutating func _setTimecode(clamping cmTime: CMTime) { - let fraction = Fraction(Int(cmTime.value), Int(cmTime.timescale)) + let fraction = Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)) _setTimecode(clamping: fraction) } @@ -103,7 +103,7 @@ extension Timecode { /// - Note: Many AVFoundation and Core Media objects utilize `CMTime` as a way to represent /// times and durations. mutating func _setTimecode(wrapping cmTime: CMTime) { - let fraction = Fraction(Int(cmTime.value), Int(cmTime.timescale)) + let fraction = Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)) _setTimecode(wrapping: fraction) } @@ -114,7 +114,7 @@ extension Timecode { /// - Note: Many AVFoundation and Core Media objects utilize `CMTime` as a way to represent /// times and durations. mutating func _setTimecode(rawValues cmTime: CMTime) { - let fraction = Fraction(Int(cmTime.value), Int(cmTime.timescale)) + let fraction = Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)) _setTimecode(rawValues: fraction) } } diff --git a/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Conversions.swift b/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Conversions.swift index d200579d..507a9f57 100644 --- a/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Conversions.swift +++ b/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Conversions.swift @@ -122,7 +122,7 @@ extension TimecodeFrameRate { drop: Bool = false ) { self.init( - rate: Fraction(Int(cmTime.value), Int(cmTime.timescale)), + rate: Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)), drop: drop ) } @@ -141,7 +141,7 @@ extension TimecodeFrameRate { drop: Bool = false ) { self.init( - frameDuration: Fraction(Int(cmTime.value), Int(cmTime.timescale)), + frameDuration: Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)), drop: drop ) } diff --git a/Sources/SwiftTimecodeCore/VideoFrameRate/VideoFrameRate Conversions.swift b/Sources/SwiftTimecodeCore/VideoFrameRate/VideoFrameRate Conversions.swift index 81b63e67..ec8ba2ff 100644 --- a/Sources/SwiftTimecodeCore/VideoFrameRate/VideoFrameRate Conversions.swift +++ b/Sources/SwiftTimecodeCore/VideoFrameRate/VideoFrameRate Conversions.swift @@ -151,7 +151,7 @@ extension VideoFrameRate { interlaced: Bool = false ) { self.init( - rate: Fraction(Int(cmTime.value), Int(cmTime.timescale)), + rate: Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)), interlaced: interlaced ) } @@ -170,7 +170,7 @@ extension VideoFrameRate { interlaced: Bool = false ) { self.init( - frameDuration: Fraction(Int(cmTime.value), Int(cmTime.timescale)), + frameDuration: Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)), interlaced: interlaced ) } From 1ad3e64636922e2ee5bcd6bcf7d60af223636b15 Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Fri, 14 Aug 2026 16:55:11 -0700 Subject: [PATCH 11/12] Updated unit tests --- .../TimecodeFrameRate Properties Tests.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift b/Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift index e9e0ad19..2e7d17cc 100644 --- a/Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift +++ b/Tests/SwiftTimecodeCoreTests/TimecodeFrameRate/TimecodeFrameRate Properties Tests.swift @@ -53,15 +53,6 @@ struct TimecodeFrameRate_Properties_Tests { == 2_592_000 * 80 ) - // Previously fenced off from armv7/i386 because these literals overflow a - // 32-bit `Int`. They no longer need to be: `PlatformInt` is `Int64` on - // 32-bit platforms, so typing the expected value to it lets the assertion - // run everywhere. On 64-bit the arithmetic is unchanged — `PlatformInt` - // IS `Int` there. - // - // Worth un-fencing rather than leaving: this is the assertion for the - // exact bound that used to trap on 32-bit, so fencing it hid the bug from - // the only platform that had it. #expect( frameRate.maxTotalSubFrames( in: .max100Days, From 8f7f31534103d3e900a7d2b6643c52671e5a3da2 Mon Sep 17 00:00:00 2001 From: Steffan Andrews Date: Fri, 14 Aug 2026 16:58:39 -0700 Subject: [PATCH 12/12] Respelled `Int` casts as `PlatformInt` when constructing `Fraction` --- .../Timecode/Source/Timecode Rational CMTime.swift | 8 ++++---- .../TimecodeFrameRate/TimecodeFrameRate Conversions.swift | 4 ++-- .../VideoFrameRate/VideoFrameRate Conversions.swift | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational CMTime.swift b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational CMTime.swift index 785ba4a7..08d313ed 100644 --- a/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational CMTime.swift +++ b/Sources/SwiftTimecodeCore/Timecode/Source/Timecode Rational CMTime.swift @@ -81,7 +81,7 @@ extension Timecode { /// /// - Throws: ``ValidationError`` mutating func _setTimecode(exactly: CMTime) throws { - let fraction = Fraction(PlatformInt(exactly.value), Int(exactly.timescale)) + let fraction = Fraction(PlatformInt(exactly.value), PlatformInt(exactly.timescale)) try _setTimecode(exactly: fraction) } @@ -92,7 +92,7 @@ extension Timecode { /// - Note: Many AVFoundation and Core Media objects utilize `CMTime` as a way to represent /// times and durations. mutating func _setTimecode(clamping cmTime: CMTime) { - let fraction = Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)) + let fraction = Fraction(PlatformInt(cmTime.value), PlatformInt(cmTime.timescale)) _setTimecode(clamping: fraction) } @@ -103,7 +103,7 @@ extension Timecode { /// - Note: Many AVFoundation and Core Media objects utilize `CMTime` as a way to represent /// times and durations. mutating func _setTimecode(wrapping cmTime: CMTime) { - let fraction = Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)) + let fraction = Fraction(PlatformInt(cmTime.value), PlatformInt(cmTime.timescale)) _setTimecode(wrapping: fraction) } @@ -114,7 +114,7 @@ extension Timecode { /// - Note: Many AVFoundation and Core Media objects utilize `CMTime` as a way to represent /// times and durations. mutating func _setTimecode(rawValues cmTime: CMTime) { - let fraction = Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)) + let fraction = Fraction(PlatformInt(cmTime.value), PlatformInt(cmTime.timescale)) _setTimecode(rawValues: fraction) } } diff --git a/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Conversions.swift b/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Conversions.swift index 507a9f57..0c281985 100644 --- a/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Conversions.swift +++ b/Sources/SwiftTimecodeCore/TimecodeFrameRate/TimecodeFrameRate Conversions.swift @@ -122,7 +122,7 @@ extension TimecodeFrameRate { drop: Bool = false ) { self.init( - rate: Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)), + rate: Fraction(PlatformInt(cmTime.value), PlatformInt(cmTime.timescale)), drop: drop ) } @@ -141,7 +141,7 @@ extension TimecodeFrameRate { drop: Bool = false ) { self.init( - frameDuration: Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)), + frameDuration: Fraction(PlatformInt(cmTime.value), PlatformInt(cmTime.timescale)), drop: drop ) } diff --git a/Sources/SwiftTimecodeCore/VideoFrameRate/VideoFrameRate Conversions.swift b/Sources/SwiftTimecodeCore/VideoFrameRate/VideoFrameRate Conversions.swift index ec8ba2ff..0116243b 100644 --- a/Sources/SwiftTimecodeCore/VideoFrameRate/VideoFrameRate Conversions.swift +++ b/Sources/SwiftTimecodeCore/VideoFrameRate/VideoFrameRate Conversions.swift @@ -151,7 +151,7 @@ extension VideoFrameRate { interlaced: Bool = false ) { self.init( - rate: Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)), + rate: Fraction(PlatformInt(cmTime.value), PlatformInt(cmTime.timescale)), interlaced: interlaced ) } @@ -170,7 +170,7 @@ extension VideoFrameRate { interlaced: Bool = false ) { self.init( - frameDuration: Fraction(PlatformInt(cmTime.value), Int(cmTime.timescale)), + frameDuration: Fraction(PlatformInt(cmTime.value), PlatformInt(cmTime.timescale)), interlaced: interlaced ) }