diff --git a/VoidLink/Base.lproj/iPad.storyboard b/VoidLink/Base.lproj/iPad.storyboard
index 0abcef6f..4d7da8ef 100644
--- a/VoidLink/Base.lproj/iPad.storyboard
+++ b/VoidLink/Base.lproj/iPad.storyboard
@@ -411,6 +411,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2302,6 +2338,10 @@
+
+
+
+
diff --git a/VoidLink/Base.lproj/iPhone.storyboard b/VoidLink/Base.lproj/iPhone.storyboard
index dc6824b5..93a44458 100644
--- a/VoidLink/Base.lproj/iPhone.storyboard
+++ b/VoidLink/Base.lproj/iPhone.storyboard
@@ -2548,6 +2548,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3569,6 +3605,10 @@
+
+
+
+
diff --git a/VoidLink/Database/DataManager.h b/VoidLink/Database/DataManager.h
index 4fbef2e1..9340c8a4 100644
--- a/VoidLink/Database/DataManager.h
+++ b/VoidLink/Database/DataManager.h
@@ -130,6 +130,9 @@ typedef NS_ENUM(NSInteger, PencilTickMode) {
graphOpacity:(NSInteger)graphOpacity
renderingBackend:(NSInteger)renderingBackend
framePacingMode:(NSInteger)framePacingMode
+ interpolationMaximumDimension:(NSInteger)interpolationMaximumDimension
+ interpolationMaximumPixelCount:(NSInteger)interpolationMaximumPixelCount
+ streamDimensionScale:(CGFloat)streamDimensionScale
sendDummyEvent:(BOOL)sendDummyEvent
rememberFoldState:(BOOL)rememberFoldState
singleTapSensitivy:(CGFloat)singleTapSensitivy
diff --git a/VoidLink/Database/DataManager.m b/VoidLink/Database/DataManager.m
index a41c6a15..299ce92c 100644
--- a/VoidLink/Database/DataManager.m
+++ b/VoidLink/Database/DataManager.m
@@ -106,6 +106,9 @@
graphOpacity:(NSInteger)graphOpacity
renderingBackend:(NSInteger)renderingBackend
framePacingMode:(NSInteger)framePacingMode
+ interpolationMaximumDimension:(NSInteger)interpolationMaximumDimension
+ interpolationMaximumPixelCount:(NSInteger)interpolationMaximumPixelCount
+ streamDimensionScale:(CGFloat)streamDimensionScale
sendDummyEvent:(BOOL)sendDummyEvent
rememberFoldState:(BOOL)rememberFoldState
singleTapSensitivy:(CGFloat)singleTapSensitivy
@@ -193,6 +196,9 @@
settingsToSave.graphOpacity = [NSNumber numberWithInteger:graphOpacity];
settingsToSave.renderingBackend = [NSNumber numberWithInteger:renderingBackend];
settingsToSave.framePacingMode = [NSNumber numberWithInteger:framePacingMode];
+ settingsToSave.interpolationMaximumDimension = [NSNumber numberWithInteger:interpolationMaximumDimension];
+ settingsToSave.interpolationMaximumPixelCount = [NSNumber numberWithInteger:interpolationMaximumPixelCount];
+ settingsToSave.streamDimensionScale = [NSNumber numberWithDouble:streamDimensionScale];
settingsToSave.sendDummyEvent = sendDummyEvent;
settingsToSave.singleTapSensitivity = [NSNumber numberWithDouble:singleTapSensitivy];
settingsToSave.hapticEngine = [NSNumber numberWithInteger:hapticEngine];
@@ -370,4 +376,3 @@
}
@end
-
diff --git a/VoidLink/Database/TemporarySettings.h b/VoidLink/Database/TemporarySettings.h
index 7fb00ff5..80fd3fe9 100644
--- a/VoidLink/Database/TemporarySettings.h
+++ b/VoidLink/Database/TemporarySettings.h
@@ -99,6 +99,9 @@
@property (nonatomic) BOOL ctrlDownForPinch;
@property (nonatomic) BOOL passthroughGestures;
@property (nonatomic, retain) NSNumber * framePacingMode;
+@property (nonatomic, retain) NSNumber * interpolationMaximumDimension;
+@property (nonatomic, retain) NSNumber * interpolationMaximumPixelCount;
+@property (nonatomic, retain) NSNumber * streamDimensionScale;
@property (nonatomic, retain) NSNumber * scrollSensitivity;
@property (nonatomic, retain) NSNumber * pinchSensitivity;
@property (nonatomic, retain) NSNumber * relativeTouchSlideThreshold;
diff --git a/VoidLink/Database/TemporarySettings.m b/VoidLink/Database/TemporarySettings.m
index 62db404f..9e4b639e 100644
--- a/VoidLink/Database/TemporarySettings.m
+++ b/VoidLink/Database/TemporarySettings.m
@@ -55,6 +55,9 @@
self.graphOpacity = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"graphOpacity"]];
self.renderingBackend = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"renderingBackend"]];
self.framePacingMode = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"framePacingMode"]];
+ self.interpolationMaximumDimension = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"interpolationMaximumDimension"]];
+ self.interpolationMaximumPixelCount = [NSNumber numberWithInteger:[[NSUserDefaults standardUserDefaults] integerForKey:@"interpolationMaximumPixelCount"]];
+ self.streamDimensionScale = [NSNumber numberWithDouble:[[NSUserDefaults standardUserDefaults] doubleForKey:@"streamDimensionScale"]];
NSInteger _screenSize = [[NSUserDefaults standardUserDefaults] integerForKey:@"streamResolution"];
switch (_screenSize) {
@@ -135,6 +138,9 @@
self.graphOpacity = settings.graphOpacity;
self.renderingBackend = settings.renderingBackend;
self.framePacingMode = settings.framePacingMode;
+ self.interpolationMaximumDimension = settings.interpolationMaximumDimension ?: @(0);
+ self.interpolationMaximumPixelCount = settings.interpolationMaximumPixelCount ?: @(0);
+ self.streamDimensionScale = settings.streamDimensionScale ?: @(1);
self.sendDummyEvent = settings.sendDummyEvent;
self.rememberFoldState = settings.rememberFoldState;
self.gyroBiasX = settings.gyroBiasX;
diff --git a/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents b/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents
index 083893b0..d9366ed1 100644
--- a/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents
+++ b/VoidLink/Limelight.xcdatamodeld/VoidLink v1.0.xcdatamodel/contents
@@ -74,6 +74,8 @@
+
+
@@ -115,6 +117,7 @@
+
diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings
index 7d58ea11..19880a6e 100644
--- a/VoidLink/Localization/Localizable.xcstrings
+++ b/VoidLink/Localization/Localizable.xcstrings
@@ -9304,6 +9304,46 @@
}
}
},
+ "Interpolation Resolution" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "插帧分辨率"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "影格插補解析度"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "影格插補解析度"
+ }
+ }
+ }
+ },
+ "Most devices currently support frame interpolation only for 8-bit YUV 4:2:0 SDR video. In this case, if HDR or YUV 4:4:4 is enabled, intermediate frames will still be generated in 8-bit YUV 4:2:0 SDR." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "目前多数设备仅支持 8-bit YUV420 SDR 视频插帧。这种情况下,如果您打开了 HDR 或 YUV444 选项,中间帧将依然按 8-bit YUV420 SDR 格式生成。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "目前多數裝置僅支援 8-bit YUV420 SDR 影片影格插補。在這種情況下,如果您開啟 HDR 或 YUV444 選項,中間影格仍會以 8-bit YUV420 SDR 格式產生。"
+ }
+ }
+ }
+ },
"Interpolation failed for an unknown reason." : {
"extractionState" : "manual",
"localizations" : {
@@ -17209,6 +17249,29 @@
}
}
},
+ "Scaled Stream Resolution" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "串流分辨率缩放"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "串流解析度縮放"
+ }
+ },
+ "zh-HK" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "串流解析度縮放"
+ }
+ }
+ }
+ },
"Slow connection to PC, Reduce your bitrate" : {
"extractionState" : "manual",
"localizations" : {
diff --git a/VoidLink/Stream/FrameInterpolator.swift b/VoidLink/Stream/FrameInterpolator.swift
index 37762080..0fb0d23d 100644
--- a/VoidLink/Stream/FrameInterpolator.swift
+++ b/VoidLink/Stream/FrameInterpolator.swift
@@ -11,10 +11,40 @@ import CoreVideo
import ObjectiveC.runtime
import VideoToolbox
+@objcMembers
+final class InterpolationResolutionConfiguration: NSObject {
+ let maximumDimension: Int
+ let maximumPixelCount: Int
+ let dimensions: CMVideoDimensions
+
+ init(
+ maximumDimension: Int,
+ maximumPixelCount: Int,
+ dimensions: CMVideoDimensions
+ ) {
+ self.maximumDimension = maximumDimension
+ self.maximumPixelCount = maximumPixelCount
+ self.dimensions = dimensions
+ }
+}
+
@objcMembers
final class FrameInterpolator: NSObject {
typealias Completion = (NSArray) -> Void
+ enum DimensionSelectionStrategy {
+ case maximumScale
+ case aspectRatioMatched
+ }
+
+ @objc enum ResolutionTier:Int {
+ case system
+ case p480
+ case p720
+ case p1080
+ case disabled
+ }
+
private struct PendingFrame {
let frame: Frame
let completion: Completion
@@ -22,15 +52,25 @@ final class FrameInterpolator: NSObject {
private let queue = DispatchQueue(label: "com.voidlink.FrameInterpolator", qos: .userInteractive)
private var processor: AnyObject?
+ private var pixelTransferSession: AnyObject?
+ private var interpolationSourcePixelBufferPool: CVPixelBufferPool?
private var pixelBufferPool: CVPixelBufferPool?
private var formatDescription: CMVideoFormatDescription?
- private var previousFrame: Frame?
+ private var previousInterpolationPixelBuffer: CVPixelBuffer?
+ private var previousInterpolationPTS = CMTime.invalid
private var pendingFrames: [PendingFrame] = []
private var isProcessing = false
+ private var isPaused = false
private var resetRequested = false
+ private var resetResolutionTierRequested = false
+ private var configuredResolutionTier = ResolutionTier.system
+ private var resolutionTier = ResolutionTier.system
+ private var configuredMaximumDimension = 0
+ private var configuredMaximumPixelCount = 0
private var width = 0
private var height = 0
- private var slowUntil = CFTimeInterval(0)
+ private var sourceWidth = 0
+ private var sourceHeight = 0
private var completedProcessingCount = 0
private var consecutiveSlowFrames = 0
private var loggedEvents = Set()
@@ -44,6 +84,21 @@ final class FrameInterpolator: NSObject {
}
}
+ @objc(initWithResolutionTierRawValue:)
+ convenience init(resolutionTierRawValue: Int) {
+ self.init()
+ let tier = ResolutionTier(rawValue: resolutionTierRawValue) ?? .system
+ configuredResolutionTier = tier == .disabled ? .system : tier
+ resolutionTier = configuredResolutionTier
+ }
+
+ @objc(initWithMaximumDimension:maximumPixelCount:)
+ convenience init(maximumDimension: Int, maximumPixelCount: Int) {
+ self.init()
+ configuredMaximumDimension = max(0, maximumDimension)
+ configuredMaximumPixelCount = max(0, maximumPixelCount)
+ }
+
@objc static let deviceSupportsInterpolation: Bool = {
#if targetEnvironment(simulator)
return false
@@ -55,6 +110,72 @@ final class FrameInterpolator: NSObject {
return false
#endif
}()
+
+ private static let runtimeSupportedPixelFormats: [OSType] = {
+ #if targetEnvironment(simulator)
+ return []
+ #else
+ guard
+ #available(iOS 26.0, tvOS 26.0, *),
+ VTLowLatencyFrameInterpolationConfiguration.isSupported,
+ let configuration = VTLowLatencyFrameInterpolationConfiguration(
+ frameWidth: 1280,
+ frameHeight: 720,
+ numberOfInterpolatedFrames: 1
+ )
+ else {
+ return []
+ }
+
+ return configuration.supportedPixelFormats
+ #endif
+ }()
+
+ private static let pixelFormatTraits: [OSType: (bitDepth: Int, range: Int, chroma: Int)] = [
+ kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange: (8, 0, 0),
+ kCVPixelFormatType_420YpCbCr8BiPlanarFullRange: (8, 1, 0),
+ kCVPixelFormatType_422YpCbCr8BiPlanarVideoRange: (8, 0, 1),
+ kCVPixelFormatType_422YpCbCr8BiPlanarFullRange: (8, 1, 1),
+ kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange: (8, 0, 2),
+ kCVPixelFormatType_444YpCbCr8BiPlanarFullRange: (8, 1, 2),
+ kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange: (10, 0, 0),
+ kCVPixelFormatType_420YpCbCr10BiPlanarFullRange: (10, 1, 0),
+ kCVPixelFormatType_422YpCbCr10BiPlanarVideoRange: (10, 0, 1),
+ kCVPixelFormatType_422YpCbCr10BiPlanarFullRange: (10, 1, 1),
+ kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange: (10, 0, 2),
+ kCVPixelFormatType_444YpCbCr10BiPlanarFullRange: (10, 1, 2),
+ ]
+
+ @objc static let hdrSupported: Bool = {
+ runtimeSupportedPixelFormats.contains {
+ pixelFormatTraits[$0]?.bitDepth == 10
+ }
+ }()
+
+ @objc(supportedPixelFormatClosestTo:)
+ static func supportedPixelFormat(closestTo nativePixelFormat: OSType) -> OSType {
+ guard let firstSupportedFormat = runtimeSupportedPixelFormats.first else {
+ return 0
+ }
+ if runtimeSupportedPixelFormats.contains(nativePixelFormat) {
+ return nativePixelFormat
+ }
+ guard let nativeTraits = pixelFormatTraits[nativePixelFormat] else {
+ return firstSupportedFormat
+ }
+
+ return runtimeSupportedPixelFormats.min { lhs, rhs in
+ func distance(from format: OSType) -> Int {
+ guard let traits = pixelFormatTraits[format] else {
+ return Int.max
+ }
+ return abs(traits.bitDepth - nativeTraits.bitDepth) * 100
+ + abs(traits.range - nativeTraits.range) * 10
+ + abs(traits.chroma - nativeTraits.chroma)
+ }
+ return distance(from: lhs) < distance(from: rhs)
+ } ?? firstSupportedFormat
+ }
static var dimensionLimitsAreKnown: Bool = {
#if targetEnvironment(simulator)
@@ -77,7 +198,48 @@ final class FrameInterpolator: NSObject {
#endif
}()
+ static func runtimeMaximumInterpolationDimensions() -> CMVideoDimensions {
+ #if targetEnvironment(simulator)
+ return CMVideoDimensions(width: 0, height: 0)
+ #else
+ guard #available(iOS 26.0, tvOS 26.0, *) else {
+ return CMVideoDimensions(width: 0, height: 0)
+ }
+
+ let limits = VTLowLatencyFrameInterpolationConfiguration
+ .runtimeDimensionLimits(spatialScaleFactor: 1)
+ guard
+ let maximumDimension = limits.maximumDimension,
+ let maximumPixelCount = limits.maximumPixelCount,
+ maximumDimension > 0,
+ maximumPixelCount > 0
+ else {
+ return CMVideoDimensions(width: 0, height: 0)
+ }
+
+ return CMVideoDimensions(
+ width: Int32(maximumDimension),
+ height: Int32(maximumPixelCount / maximumDimension)
+ )
+ #endif
+ }
+
+ static func runtimeMaximumInterpolationPixelCount() -> Int {
+ #if targetEnvironment(simulator)
+ return 0
+ #else
+ guard #available(iOS 26.0, tvOS 26.0, *) else {
+ return 0
+ }
+
+ return VTLowLatencyFrameInterpolationConfiguration
+ .runtimeDimensionLimits(spatialScaleFactor: 1)
+ .maximumPixelCount ?? 0
+ #endif
+ }
+
static func interpolatableDimensionsBy(_ dimensions: CMVideoDimensions) -> CMVideoDimensions {
+
guard deviceSupportsInterpolation else {
return CMVideoDimensions(width: 0, height: 0)
}
@@ -132,6 +294,139 @@ final class FrameInterpolator: NSObject {
)
}
+ static func interpolatableDimensionsBy480p(_ dimensions: CMVideoDimensions) -> CMVideoDimensions {
+ guard deviceSupportsInterpolation else {
+ return CMVideoDimensions(width: 0, height: 0)
+ }
+
+ return constrainedDimensions(
+ dimensions,
+ maximumDimension: 854,
+ maximumPixelCount: 854 * 480
+ )
+ }
+
+ @objc(interpolatableDimensionsBy:maximumDimension:maximumPixelCount:)
+ static func interpolatableDimensionsBy(
+ _ dimensions: CMVideoDimensions,
+ maximumDimension: Int,
+ maximumPixelCount: Int
+ ) -> CMVideoDimensions {
+ constrainedDimensions(
+ dimensions,
+ maximumDimension: maximumDimension,
+ maximumPixelCount: maximumPixelCount
+ )
+ }
+
+ @objc(resolutionConfigurationForDimensions:level:)
+ static func resolutionConfiguration(
+ for dimensions: CMVideoDimensions,
+ level: Float
+ ) -> InterpolationResolutionConfiguration {
+ let sliderLimits = interpolationSliderLimits(for: dimensions)
+ let clampedLevel = min(1, max(0, Double(level)))
+ let maximumDimension = Int(
+ (Double(sliderLimits.minimumDimension) +
+ Double(sliderLimits.maximumDimension - sliderLimits.minimumDimension) * clampedLevel)
+ .rounded()
+ )
+
+ let maximumPixelCount: Int
+ if clampedLevel <= 0 {
+ maximumPixelCount = 854 * 480
+ } else if clampedLevel >= 1 {
+ maximumPixelCount = sliderLimits.maximumPixelCount
+ } else {
+ maximumPixelCount = min(
+ Int((Double(maximumDimension * maximumDimension) / sliderLimits.aspectRatio).rounded()),
+ sliderLimits.maximumPixelCount
+ )
+ }
+
+ return InterpolationResolutionConfiguration(
+ maximumDimension: maximumDimension,
+ maximumPixelCount: maximumPixelCount,
+ dimensions: constrainedDimensions(
+ dimensions,
+ maximumDimension: maximumDimension,
+ maximumPixelCount: maximumPixelCount
+ )
+ )
+ }
+
+ @objc(interpolationLevelForDimensions:savedMaximumDimension:)
+ static func interpolationLevel(
+ for dimensions: CMVideoDimensions,
+ savedMaximumDimension: Int
+ ) -> Float {
+ let limits = interpolationSliderLimits(for: dimensions)
+ guard savedMaximumDimension > 0,
+ limits.maximumDimension != limits.minimumDimension else {
+ return 1
+ }
+
+ return Float(min(1, max(0,
+ Double(savedMaximumDimension - limits.minimumDimension) /
+ Double(limits.maximumDimension - limits.minimumDimension)
+ )))
+ }
+
+ @objc(scaledStreamDimensionsWithPresetDimensions:interpolationDimensions:scale:)
+ static func scaledStreamDimensions(
+ presetDimensions: CMVideoDimensions,
+ interpolationDimensions: CMVideoDimensions,
+ scale: Float
+ ) -> CMVideoDimensions {
+ let clampedScale = min(1, max(0, Double(scale)))
+ let width = Int(
+ (Double(interpolationDimensions.width) +
+ Double(presetDimensions.width - interpolationDimensions.width) * clampedScale)
+ .rounded()
+ )
+ let height = Int(
+ (Double(interpolationDimensions.height) +
+ Double(presetDimensions.height - interpolationDimensions.height) * clampedScale)
+ .rounded()
+ )
+
+ return videoDimensions(
+ width: max(4, width & ~3),
+ height: max(4, height & ~3)
+ )
+ }
+
+ private static func interpolationSliderLimits(
+ for dimensions: CMVideoDimensions
+ ) -> (
+ minimumDimension: Int,
+ maximumDimension: Int,
+ maximumPixelCount: Int,
+ aspectRatio: Double
+ ) {
+ let systemDimensions = interpolatableDimensionsBy(dimensions)
+ let systemDimensionsAreValid = systemDimensions.width > 0 && systemDimensions.height > 0
+ let maximumDimension = systemDimensionsAreValid
+ ? max(Int(systemDimensions.width), Int(systemDimensions.height))
+ : 1280
+ let maximumPixelCount = systemDimensionsAreValid
+ ? Int(systemDimensions.width) * Int(systemDimensions.height)
+ : 1280 * 720
+ let aspectRatio = Double(maximumDimension * maximumDimension) /
+ Double(maximumPixelCount)
+ let minimumDimension = min(
+ Int((Double(854 * 480) * aspectRatio).squareRoot().rounded()),
+ maximumDimension
+ )
+
+ return (
+ minimumDimension,
+ maximumDimension,
+ maximumPixelCount,
+ aspectRatio
+ )
+ }
+
@objc(sourcePixelBufferAttributesForDimensions:)
static func sourcePixelBufferAttributes(for dimensions: CMVideoDimensions) -> NSDictionary? {
#if targetEnvironment(simulator)
@@ -158,7 +453,8 @@ final class FrameInterpolator: NSObject {
private static func constrainedDimensions(
_ dimensions: CMVideoDimensions,
maximumDimension: Int,
- maximumPixelCount: Int
+ maximumPixelCount: Int,
+ strategy: DimensionSelectionStrategy = .aspectRatioMatched
) -> CMVideoDimensions {
guard
dimensions.width > 0,
@@ -190,28 +486,142 @@ final class FrameInterpolator: NSObject {
return dimensions
}
+ let constrainedDimensions: CMVideoDimensions
+ switch strategy {
+ case .maximumScale:
+ constrainedDimensions = maximumScaleDimensions(
+ width: width,
+ height: height,
+ maximumDimension: maximumDimension,
+ maximumPixelCount: maximumPixelCount
+ )
+ case .aspectRatioMatched:
+ constrainedDimensions = aspectRatioMatchedDimensions(
+ width: width,
+ height: height,
+ maximumDimension: maximumDimension,
+ maximumPixelCount: maximumPixelCount
+ )
+ }
+
+ guard
+ constrainedDimensions.width > 0,
+ constrainedDimensions.height > 0
+ else {
+ return CMVideoDimensions(width: 0, height: 0)
+ }
+
+ return constrainedDimensions
+ }
+
+ private static func maximumScaleDimensions(
+ width: Int,
+ height: Int,
+ maximumDimension: Int,
+ maximumPixelCount: Int
+ ) -> CMVideoDimensions {
let sourceWidth = Double(width)
let sourceHeight = Double(height)
let dimensionScale = Double(maximumDimension) / max(sourceWidth, sourceHeight)
let pixelCountScale = sqrt(Double(maximumPixelCount) / (sourceWidth * sourceHeight))
let scale = min(dimensionScale, pixelCountScale)
-
- let scaledWidth = Int((sourceWidth * scale).rounded(.down)) & ~(31)
- let scaledHeight = Int((sourceHeight * scale).rounded(.down)) & ~(31)
+ let interpolationAlignment = 16
+ let scaledWidth = Int((sourceWidth * scale).rounded(.down)) & ~(interpolationAlignment - 1)
+ let scaledHeight = Int((sourceHeight * scale).rounded(.down)) & ~(interpolationAlignment - 1)
+
+ return videoDimensions(width: scaledWidth, height: scaledHeight)
+ }
+
+ private static func aspectRatioMatchedDimensions(
+ width: Int,
+ height: Int,
+ maximumDimension: Int,
+ maximumPixelCount: Int
+ ) -> CMVideoDimensions {
+ let interpolationAlignment = 16
+ let sourceWidth = Double(width)
+ let sourceHeight = Double(height)
+ let sourceAspectRatio = sourceWidth / sourceHeight
+ let dimensionScale = Double(maximumDimension) / max(sourceWidth, sourceHeight)
+ let pixelCountScale = sqrt(Double(maximumPixelCount) / (sourceWidth * sourceHeight))
+ let idealScale = min(1, dimensionScale, pixelCountScale)
+ let minimumCandidateArea = sourceWidth * idealScale * sourceHeight * idealScale * 0.9
+ let maximumCandidateWidth = min(width, maximumDimension) & ~(interpolationAlignment - 1)
+ let maximumCandidateHeight = min(height, maximumDimension)
+
+ var bestWidth = 0
+ var bestHeight = 0
+ var bestArea = 0
+ var bestAspectRatioError = Double.greatestFiniteMagnitude
+
+ for candidateWidth in stride(
+ from: interpolationAlignment,
+ through: maximumCandidateWidth,
+ by: interpolationAlignment
+ ) {
+ let exactHeight = Double(candidateWidth) / sourceAspectRatio
+ let lowerHeight = Int(exactHeight.rounded(.down)) & ~(interpolationAlignment - 1)
+
+ for candidateHeight in [lowerHeight, lowerHeight + interpolationAlignment] {
+ guard
+ candidateHeight > 0,
+ candidateHeight <= maximumCandidateHeight
+ else {
+ continue
+ }
+
+ let (candidateArea, areaOverflow) = candidateWidth.multipliedReportingOverflow(
+ by: candidateHeight
+ )
+ guard
+ !areaOverflow,
+ candidateArea <= maximumPixelCount,
+ Double(candidateArea) >= minimumCandidateArea
+ else {
+ continue
+ }
+
+ let candidateAspectRatio = Double(candidateWidth) / Double(candidateHeight)
+ let aspectRatioError = abs(candidateAspectRatio / sourceAspectRatio - 1)
+ let hasBetterAspectRatio = aspectRatioError < bestAspectRatioError
+ let hasEqualAspectRatio = abs(aspectRatioError - bestAspectRatioError) < 1e-12
+
+ if hasBetterAspectRatio || (hasEqualAspectRatio && candidateArea > bestArea) {
+ bestWidth = candidateWidth
+ bestHeight = candidateHeight
+ bestArea = candidateArea
+ bestAspectRatioError = aspectRatioError
+ }
+ }
+ }
+
+ guard bestWidth > 0, bestHeight > 0 else {
+ return maximumScaleDimensions(
+ width: width,
+ height: height,
+ maximumDimension: maximumDimension,
+ maximumPixelCount: maximumPixelCount
+ )
+ }
+
+ return videoDimensions(width: bestWidth, height: bestHeight)
+ }
+
+ private static func videoDimensions(width: Int, height: Int) -> CMVideoDimensions {
guard
- scaledWidth > 0,
- scaledHeight > 0,
- scaledWidth <= Int(Int32.max),
- scaledHeight <= Int(Int32.max)
+ width > 0,
+ height > 0,
+ width <= Int(Int32.max),
+ height <= Int(Int32.max)
else {
return CMVideoDimensions(width: 0, height: 0)
}
- return CMVideoDimensions(width: Int32(scaledWidth), height: Int32(scaledHeight))
+ return CMVideoDimensions(width: Int32(width), height: Int32(height))
}
var isAvailable: Bool {
- isEnabled && Self.deviceSupportsInterpolation
+ isEnabled && Self.deviceSupportsInterpolation && resolutionTier != .disabled
}
func reset() {
@@ -220,6 +630,31 @@ final class FrameInterpolator: NSObject {
}
}
+ func setPaused(_ paused: Bool) {
+ queue.async {
+ guard self.isPaused != paused else {
+ return
+ }
+
+ self.isPaused = paused
+ guard paused else {
+ return
+ }
+
+ let pendingFrames = self.pendingFrames
+ self.pendingFrames.removeAll()
+ for pendingFrame in pendingFrames {
+ pendingFrame.completion([pendingFrame.frame])
+ }
+
+ if self.isProcessing {
+ self.resetRequested = true
+ } else {
+ self.resetLocked()
+ }
+ }
+ }
+
func processFrame(_ frame: Frame, completion: @escaping Completion) {
queue.async { [weak self] in
guard let self else {
@@ -227,7 +662,7 @@ final class FrameInterpolator: NSObject {
return
}
- guard !self.resetRequested else {
+ guard !self.isPaused, !self.resetRequested else {
completion([frame])
return
}
@@ -245,11 +680,12 @@ final class FrameInterpolator: NSObject {
let pendingFrame = pendingFrames.removeFirst()
let frame = pendingFrame.frame
let completion = pendingFrame.completion
- guard isAvailable, CACurrentMediaTime() >= slowUntil else {
+ guard isAvailable else {
if isEnabled && !Self.deviceSupportsInterpolation {
logOnce("unavailable", "disabled: VT low-latency frame interpolation is unsupported on this OS/device")
}
- previousFrame = frame
+ previousInterpolationPixelBuffer = nil
+ previousInterpolationPTS = .invalid
completion([frame])
drainPendingFrames()
return
@@ -257,15 +693,12 @@ final class FrameInterpolator: NSObject {
if frame.frameType == FRAME_TYPE_IDR {
resetLocked(keepPendingFrames: true)
- previousFrame = frame
- completion([frame])
- drainPendingFrames()
- return
}
guard let currentPixelBuffer = frame.imageBuffer else {
logOnce("missing-image-buffer", "input Frame has no CVPixelBuffer")
- previousFrame = frame
+ previousInterpolationPixelBuffer = nil
+ previousInterpolationPTS = .invalid
completion([frame])
drainPendingFrames()
return
@@ -276,33 +709,46 @@ final class FrameInterpolator: NSObject {
let currentPTS = frame.pts90
guard CMTIME_IS_VALID(currentPTS) else {
logOnce("invalid-pts", "input frame has an invalid PTS")
- previousFrame = frame
+ previousInterpolationPixelBuffer = nil
+ previousInterpolationPTS = .invalid
completion([frame])
drainPendingFrames()
return
}
- guard prepareSessionIfNeeded(pixelBuffer: currentPixelBuffer) else {
- previousFrame = frame
+ let startedAt = CACurrentMediaTime()
+ guard
+ prepareSessionIfNeeded(pixelBuffer: currentPixelBuffer),
+ let currentInterpolationPixelBuffer = makeInterpolationSourcePixelBuffer(
+ from: currentPixelBuffer
+ )
+ else {
+ previousInterpolationPixelBuffer = nil
+ previousInterpolationPTS = .invalid
completion([frame])
drainPendingFrames()
return
}
- guard let previousFrame, let previousPixelBuffer = previousFrame.imageBuffer else {
- previousFrame = frame
+ guard
+ let previousPixelBuffer = previousInterpolationPixelBuffer,
+ CMTIME_IS_VALID(previousInterpolationPTS)
+ else {
+ previousInterpolationPixelBuffer = currentInterpolationPixelBuffer
+ previousInterpolationPTS = currentPTS
completion([frame])
drainPendingFrames()
return
}
- let previousPTS = previousFrame.pts90
+ let previousPTS = previousInterpolationPTS
guard CMTIME_IS_VALID(previousPTS), CMTimeCompare(currentPTS, previousPTS) > 0 else {
logOnce(
"non-monotonic-pts",
"upstream PTS is not increasing: previous \(CMTimeGetSeconds(previousPTS)), current \(CMTimeGetSeconds(currentPTS))"
)
- self.previousFrame = frame
+ previousInterpolationPixelBuffer = currentInterpolationPixelBuffer
+ previousInterpolationPTS = currentPTS
completion([frame])
drainPendingFrames()
return
@@ -310,13 +756,15 @@ final class FrameInterpolator: NSObject {
let sourceFrameInterval = CMTimeGetSeconds(CMTimeSubtract(currentPTS, previousPTS))
#if targetEnvironment(simulator)
- self.previousFrame = frame
+ previousInterpolationPixelBuffer = currentInterpolationPixelBuffer
+ previousInterpolationPTS = currentPTS
completion([frame])
drainPendingFrames()
return
#else
guard #available(iOS 26.0, tvOS 26.0, *) else {
- self.previousFrame = frame
+ previousInterpolationPixelBuffer = currentInterpolationPixelBuffer
+ previousInterpolationPTS = currentPTS
completion([frame])
drainPendingFrames()
return
@@ -324,7 +772,7 @@ final class FrameInterpolator: NSObject {
guard
let processor = processor as? VTFrameProcessor,
- let sourceProcessorFrame = VTFrameProcessorFrame(buffer: currentPixelBuffer, presentationTimeStamp: currentPTS),
+ let sourceProcessorFrame = VTFrameProcessorFrame(buffer: currentInterpolationPixelBuffer, presentationTimeStamp: currentPTS),
let previousProcessorFrame = VTFrameProcessorFrame(buffer: previousPixelBuffer, presentationTimeStamp: previousPTS),
let destinationPixelBuffer = makeDestinationPixelBuffer(),
let destinationProcessorFrame = VTFrameProcessorFrame(
@@ -340,27 +788,35 @@ final class FrameInterpolator: NSObject {
else {
logOnce(
"parameters-failed",
- "could not create interpolation parameters; input pixel format is \(pixelFormatName(CVPixelBufferGetPixelFormatType(currentPixelBuffer)))"
+ "could not create interpolation parameters; input pixel format is \(pixelFormatName(CVPixelBufferGetPixelFormatType(currentInterpolationPixelBuffer)))"
)
- self.previousFrame = frame
+ previousInterpolationPixelBuffer = currentInterpolationPixelBuffer
+ previousInterpolationPTS = currentPTS
completion([frame])
drainPendingFrames()
return
}
+ CVBufferPropagateAttachments(
+ currentInterpolationPixelBuffer,
+ destinationPixelBuffer
+ )
+
isProcessing = true
- let startedAt = CACurrentMediaTime()
processor.process(parameters: parameters) { [self] _, error in
self.queue.async {
self.isProcessing = false
if self.resetRequested {
- self.resetLocked()
+ self.resetLocked(
+ resetResolutionTier: self.resetResolutionTierRequested
+ )
completion([frame])
return
}
- self.previousFrame = frame
+ self.previousInterpolationPixelBuffer = currentInterpolationPixelBuffer
+ self.previousInterpolationPTS = currentPTS
let elapsed = CACurrentMediaTime() - startedAt
guard
@@ -396,14 +852,8 @@ final class FrameInterpolator: NSObject {
} else {
self.consecutiveSlowFrames = 0
}
- if self.completedProcessingCount >= 10, self.consecutiveSlowFrames >= 3 {
- self.slowUntil = CACurrentMediaTime() + 1.0
- self.consecutiveSlowFrames = 0
- self.logOnce(
- "sustained-overrun",
- "processing repeatedly exceeded the source-frame interval; temporarily falling back to original frames",
- overlayText: "Interpolation overhead is too high. Falling back to normal mode.".localized
- )
+ if self.completedProcessingCount >= 10, self.consecutiveSlowFrames >= 30 {
+ // self.handleSustainedOverrun() // disable degradation for now
}
// NSLog("[FrameInterpolator] interpolated frame in %.2f ms",)
@@ -418,9 +868,11 @@ final class FrameInterpolator: NSObject {
#if targetEnvironment(simulator)
return false
#else
- let width = CVPixelBufferGetWidth(pixelBuffer)
- let height = CVPixelBufferGetHeight(pixelBuffer)
- if processor != nil, self.width == width, self.height == height {
+ let sourceWidth = CVPixelBufferGetWidth(pixelBuffer)
+ let sourceHeight = CVPixelBufferGetHeight(pixelBuffer)
+ if processor != nil,
+ self.sourceWidth == sourceWidth,
+ self.sourceHeight == sourceHeight {
return true
}
@@ -430,7 +882,17 @@ final class FrameInterpolator: NSObject {
return false
}
+ let sourceDimensions = CMVideoDimensions(
+ width: Int32(sourceWidth),
+ height: Int32(sourceHeight)
+ )
+ let interpolationDimensions = interpolationDimensions(for: sourceDimensions)
+ let width = Int(interpolationDimensions.width)
+ let height = Int(interpolationDimensions.height)
+
guard
+ width > 0,
+ height > 0,
VTLowLatencyFrameInterpolationConfiguration.isSupported,
let configuration = VTLowLatencyFrameInterpolationConfiguration(
frameWidth: width,
@@ -455,8 +917,8 @@ final class FrameInterpolator: NSObject {
&extendedBottom
)
logOnce(
- "input-layout-\(width)x\(height)",
- "input pixel buffer layout for \(width)x\(height): " +
+ "input-layout-\(sourceWidth)x\(sourceHeight)",
+ "input pixel buffer layout for \(sourceWidth)x\(sourceHeight): " +
"extended left=\(extendedLeft), right=\(extendedRight), " +
"top=\(extendedTop), bottom=\(extendedBottom)"
)
@@ -477,6 +939,7 @@ final class FrameInterpolator: NSObject {
"destination-attributes-\(width)x\(height)",
"destination pixel buffer attributes for \(width)x\(height): \(configuration.destinationPixelBufferAttributes)"
)
+ print("configuration.supportedPixelFormats \(configuration.supportedPixelFormats)")
if !configuration.supportedPixelFormats.contains(inputPixelFormat) {
let supportedFormats = configuration.supportedPixelFormats
.map(pixelFormatName)
@@ -488,6 +951,44 @@ final class FrameInterpolator: NSObject {
return false
}
+ guard let sourcePool = makeInterpolationSourcePixelBufferPool(
+ configuration: configuration,
+ pixelFormat: inputPixelFormat,
+ width: width,
+ height: height
+ ) else {
+ logOnce("source-pool-error", "failed to create the interpolation source pixel buffer pool")
+ return false
+ }
+
+ var transferSession: VTPixelTransferSession?
+ let transferStatus = VTPixelTransferSessionCreate(
+ allocator: kCFAllocatorDefault,
+ pixelTransferSessionOut: &transferSession
+ )
+ guard transferStatus == noErr, let transferSession else {
+ logOnce(
+ "pixel-transfer-session-error-\(transferStatus)",
+ "failed to create pixel transfer session: \(transferStatus)"
+ )
+ return false
+ }
+ VTSessionSetProperty(
+ transferSession,
+ key: kVTPixelTransferPropertyKey_ScalingMode,
+ value: kVTScalingMode_Normal
+ )
+ VTSessionSetProperty(
+ transferSession,
+ key: kVTPixelTransferPropertyKey_RealTime,
+ value: kCFBooleanTrue
+ )
+ VTSessionSetProperty(
+ transferSession,
+ key: kVTPixelTransferPropertyKey_DownsamplingMode,
+ value: kVTDownsamplingMode_Average
+ )
+
let processor = VTFrameProcessor()
do {
try processor.startSession(configuration: configuration)
@@ -509,18 +1010,162 @@ final class FrameInterpolator: NSObject {
return false
}
+ self.sourceWidth = sourceWidth
+ self.sourceHeight = sourceHeight
self.width = width
self.height = height
self.processor = processor
+ self.pixelTransferSession = transferSession
+ self.interpolationSourcePixelBufferPool = sourcePool
self.pixelBufferPool = pool
logOnce(
"session-started-\(width)x\(height)",
- "session started for \(width)x\(height), input pixel format \(pixelFormatName(inputPixelFormat))"
+ "session started for \(width)x\(height) from \(sourceWidth)x\(sourceHeight), input pixel format \(pixelFormatName(inputPixelFormat))"
)
return true
#endif
}
+ private func interpolationDimensions(
+ for sourceDimensions: CMVideoDimensions
+ ) -> CMVideoDimensions {
+ switch resolutionTier {
+ case .system:
+ if configuredMaximumDimension > 0, configuredMaximumPixelCount > 0 {
+ return Self.constrainedDimensions(
+ sourceDimensions,
+ maximumDimension: configuredMaximumDimension,
+ maximumPixelCount: configuredMaximumPixelCount
+ )
+ }
+ let dimensions = Self.interpolatableDimensionsBy(sourceDimensions)
+ if dimensions.width > 0, dimensions.height > 0 {
+ return dimensions
+ }
+ return Self.interpolatableDimensionsBy720p(sourceDimensions)
+ case .p1080:
+ return Self.interpolatableDimensionsBy1080p(sourceDimensions)
+ case .p720:
+ return Self.interpolatableDimensionsBy720p(sourceDimensions)
+ case .p480:
+ return Self.interpolatableDimensionsBy480p(sourceDimensions)
+ case .disabled:
+ return CMVideoDimensions(width: 0, height: 0)
+ }
+ }
+
+ private func handleSustainedOverrun() {
+ switch resolutionTier {
+ case .system:
+ resolutionTier = .p720
+ logOnce(
+ "sustained-overrun-720p",
+ "processing repeatedly exceeded the source-frame interval; reducing interpolation resolution to the 720p limit",
+ overlayText: "Interpolation overhead is too high. Reducing interpolation resolution to 720p.".localized
+ )
+ case .p1080:
+ resolutionTier = .p720
+ logOnce(
+ "sustained-overrun-1080p",
+ "processing repeatedly exceeded the source-frame interval at the 1080p limit; reducing interpolation resolution to the 720p limit",
+ overlayText: "Interpolation overhead is too high. Reducing interpolation resolution to 480p.".localized
+ )
+ case .p720:
+ resolutionTier = .p480
+ logOnce(
+ "sustained-overrun-480p",
+ "processing repeatedly exceeded the source-frame interval at the 720p limit; reducing interpolation resolution to the 480p limit",
+ overlayText: "Interpolation overhead is too high. Reducing interpolation resolution to 480p.".localized
+ )
+ case .p480:
+ resolutionTier = .disabled
+ logOnce(
+ "sustained-overrun-disabled",
+ "processing repeatedly exceeded the source-frame interval at the 480p limit; falling back to original frames",
+ overlayText: "Interpolation overhead is too high. Falling back to normal mode.".localized
+ )
+ case .disabled:
+ return
+ }
+
+ resetLocked(keepPendingFrames: true)
+ }
+
+ #if !targetEnvironment(simulator)
+ @available(iOS 26.0, tvOS 26.0, *)
+ private func makeInterpolationSourcePixelBufferPool(
+ configuration: VTLowLatencyFrameInterpolationConfiguration,
+ pixelFormat: OSType,
+ width: Int,
+ height: Int
+ ) -> CVPixelBufferPool? {
+ let requestedAttributes: [CFString: Any] = [
+ kCVPixelBufferPixelFormatTypeKey: pixelFormat,
+ kCVPixelBufferWidthKey: width,
+ kCVPixelBufferHeightKey: height,
+ kCVPixelBufferIOSurfacePropertiesKey: [:]
+ ]
+ var resolvedAttributes: CFDictionary?
+ let resolveStatus = CVPixelBufferCreateResolvedAttributesDictionary(
+ kCFAllocatorDefault,
+ [requestedAttributes as CFDictionary,
+ configuration.sourcePixelBufferAttributes as CFDictionary] as CFArray,
+ &resolvedAttributes
+ )
+ guard resolveStatus == kCVReturnSuccess, let resolvedAttributes else {
+ return nil
+ }
+
+ var pool: CVPixelBufferPool?
+ let poolStatus = CVPixelBufferPoolCreate(
+ kCFAllocatorDefault,
+ nil,
+ resolvedAttributes,
+ &pool
+ )
+ return poolStatus == kCVReturnSuccess ? pool : nil
+ }
+ #endif
+
+ private func makeInterpolationSourcePixelBuffer(
+ from sourcePixelBuffer: CVPixelBuffer
+ ) -> CVPixelBuffer? {
+ #if targetEnvironment(simulator)
+ return nil
+ #else
+ guard
+ #available(iOS 16.0, tvOS 16.0, *),
+ let pixelTransferSession,
+ let interpolationSourcePixelBufferPool
+ else {
+ return nil
+ }
+ let transferSession = pixelTransferSession as! VTPixelTransferSession
+
+ var destinationPixelBuffer: CVPixelBuffer?
+ let createStatus = CVPixelBufferPoolCreatePixelBuffer(
+ kCFAllocatorDefault,
+ interpolationSourcePixelBufferPool,
+ &destinationPixelBuffer
+ )
+ guard createStatus == kCVReturnSuccess, let destinationPixelBuffer else {
+ logOnce("downsize-buffer-error-\(createStatus)", "failed to allocate downsize buffer: \(createStatus)")
+ return nil
+ }
+
+ let transferStatus = VTPixelTransferSessionTransferImage(
+ transferSession,
+ from: sourcePixelBuffer,
+ to: destinationPixelBuffer
+ )
+ guard transferStatus == noErr else {
+ logOnce("downsize-error-\(transferStatus)", "failed to downsize frame: \(transferStatus)")
+ return nil
+ }
+ return destinationPixelBuffer
+ #endif
+ }
+
private func makeDestinationPixelBuffer() -> CVPixelBuffer? {
guard let pixelBufferPool else {
return nil
@@ -588,8 +1233,8 @@ final class FrameInterpolator: NSObject {
}
if let overlayText {
showTransientHUDText(overlayText)
- NSLog("[FrameInterpolator] %@", message)
}
+ NSLog("[FrameInterpolator] %@", message)
}
private func logOnce(_ event: String, error: Error) {
@@ -644,13 +1289,17 @@ final class FrameInterpolator: NSObject {
pendingFrames.removeAll()
guard !isProcessing else {
resetRequested = true
+ resetResolutionTierRequested = true
return
}
- resetLocked()
+ resetLocked(resetResolutionTier: true)
}
- private func resetLocked(keepPendingFrames: Bool = false) {
+ private func resetLocked(
+ keepPendingFrames: Bool = false,
+ resetResolutionTier: Bool = false
+ ) {
#if !targetEnvironment(simulator)
if #available(iOS 26.0, tvOS 26.0, *),
let processor = processor as? VTFrameProcessor {
@@ -659,16 +1308,30 @@ final class FrameInterpolator: NSObject {
#endif
processor = nil
+ #if !targetEnvironment(simulator)
+ if #available(iOS 16.0, tvOS 16.0, *),
+ let pixelTransferSession {
+ VTPixelTransferSessionInvalidate(pixelTransferSession as! VTPixelTransferSession)
+ }
+ #endif
+ pixelTransferSession = nil
+ interpolationSourcePixelBufferPool = nil
pixelBufferPool = nil
formatDescription = nil
- previousFrame = nil
+ previousInterpolationPixelBuffer = nil
+ previousInterpolationPTS = .invalid
isProcessing = false
resetRequested = false
+ resetResolutionTierRequested = false
width = 0
height = 0
- slowUntil = 0
+ sourceWidth = 0
+ sourceHeight = 0
completedProcessingCount = 0
consecutiveSlowFrames = 0
+ if resetResolutionTier {
+ resolutionTier = configuredResolutionTier
+ }
if !keepPendingFrames {
pendingFrames.removeAll()
}
diff --git a/VoidLink/Stream/VideoDecoderRenderer.h b/VoidLink/Stream/VideoDecoderRenderer.h
index 920ec247..c6f55f96 100644
--- a/VoidLink/Stream/VideoDecoderRenderer.h
+++ b/VoidLink/Stream/VideoDecoderRenderer.h
@@ -49,5 +49,7 @@
- (void)invalidateDecompressionSession;
+ (void)setFrameInterpolationEnabled:(bool)enabled;
++ (void)startOrRestartFrameInterpolation;
++ (void)stopFrameInterpolation;
@end
diff --git a/VoidLink/Stream/VideoDecoderRenderer.m b/VoidLink/Stream/VideoDecoderRenderer.m
index c812a425..9c7e9099 100644
--- a/VoidLink/Stream/VideoDecoderRenderer.m
+++ b/VoidLink/Stream/VideoDecoderRenderer.m
@@ -36,11 +36,19 @@
//#define DISPLAYLINK_VERBOSE
static BOOL kEnableFrameInterpolation = false;
+static __weak VideoDecoderRenderer *sActiveRenderer = nil;
// Private libavformat API for writing the AV1 Codec Configuration Box
extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
int write_seq_header);
+@interface VideoDecoderRenderer ()
+- (void)startOrRestartFrameInterpolation;
+- (void)stopFrameInterpolation;
+- (NSInteger)displayLinkFrameRateForInterpolationEnabled:(BOOL)enabled;
+- (void)restartDisplayLinkForInterpolationEnabled:(BOOL)enabled;
+@end
+
@implementation VideoDecoderRenderer {
dispatch_queue_t _sq, _vtq;
StreamView* _view;
@@ -60,6 +68,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
CMVideoFormatDescriptionRef _formatDescImageBuffer;
VTDecompressionSessionRef _decompressionSession;
FrameInterpolator *_frameInterpolator;
+ BOOL _frameInterpolationPaused;
CADisplayLink *_displayLink;
NSInteger _maxRefreshRate;
@@ -77,6 +86,26 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
kEnableFrameInterpolation = enabled;
}
++ (void)startOrRestartFrameInterpolation {
+ kEnableFrameInterpolation = true;
+
+ VideoDecoderRenderer *renderer;
+ @synchronized(self) {
+ renderer = sActiveRenderer;
+ }
+ [renderer startOrRestartFrameInterpolation];
+}
+
++ (void)stopFrameInterpolation {
+ kEnableFrameInterpolation = false;
+
+ VideoDecoderRenderer *renderer;
+ @synchronized(self) {
+ renderer = sActiveRenderer;
+ }
+ [renderer stopFrameInterpolation];
+}
+
- (void)reinitializeDisplayLayer
{
if (_displayLayer == nil) {
@@ -174,7 +203,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
_asyncFrameDequeue = tempSettings.asyncFrameDequeue;
NSLog(@"_asyncFrameDequeue %d", _asyncFrameDequeue);
_enableTimebase = false;
- _queueSize = kEnableFrameInterpolation ? 4 : tempSettings.frameQueueSize.intValue;
+ _queueSize = kEnableFrameInterpolation ? 8 : tempSettings.frameQueueSize.intValue;
_needRequeuing = _queueSize>0;
_frameQueue = [FrameQueue sharedInstance];
@@ -189,9 +218,115 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
name:@"ScreenChanged"
object:nil];
+ @synchronized([VideoDecoderRenderer class]) {
+ sActiveRenderer = self;
+ }
+
return self;
}
+- (NSInteger)displayLinkFrameRateForInterpolationEnabled:(BOOL)enabled {
+ NSInteger multiplier = enabled ? 2 : 1;
+ return MIN(self->_frameRate * multiplier, self->_maxRefreshRate);
+}
+
+- (void)restartDisplayLinkForInterpolationEnabled:(BOOL)enabled {
+ dispatch_block_t restartBlock = ^{
+ if (self->_renderingBackend != RENDER_AVSB || self->_displayLink == nil) {
+ return;
+ }
+
+ NSInteger targetFrameRate = [self displayLinkFrameRateForInterpolationEnabled:enabled];
+ BOOL shouldRemainPaused = appDidEnterBackgroundWithoutPip;
+ self->_displayLink.paused = YES;
+ if (@available(iOS 15.0, tvOS 15.0, *)) {
+ self->_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(targetFrameRate, targetFrameRate, targetFrameRate);
+ }
+ else {
+ self->_displayLink.preferredFramesPerSecond = targetFrameRate;
+ }
+ self->_displayLink.paused = shouldRemainPaused;
+ Log(LOG_I, @"Display link restarted at %ld FPS (interpolation %@)",
+ (long)targetFrameRate, enabled ? @"enabled" : @"disabled");
+ };
+
+ if ([NSThread isMainThread]) {
+ restartBlock();
+ }
+ else {
+ dispatch_async(dispatch_get_main_queue(), restartBlock);
+ }
+}
+
+- (void)invalidateDecompressionSession {
+ @synchronized(self) {
+ if (self->_decompressionSession != NULL) {
+ VTDecompressionSessionInvalidate(self->_decompressionSession);
+ CFRelease(self->_decompressionSession);
+ self->_decompressionSession = nil;
+ }
+ }
+}
+
+- (void)startOrRestartFrameInterpolation {
+ if (self->_displayLink == nil || self->_renderingBackend != RENDER_AVSB) {
+ return;
+ }
+
+ DataManager *dataMan = [[DataManager alloc] init];
+ TemporarySettings *settings = [dataMan getSettings];
+ NSInteger maximumDimension = settings.interpolationMaximumDimension.integerValue;
+ NSInteger maximumPixelCount = settings.interpolationMaximumPixelCount.integerValue;
+
+ dispatch_async(self->_vtq, ^{
+ FrameInterpolator *oldInterpolator = self->_frameInterpolator;
+ self->_frameInterpolator = [[FrameInterpolator alloc]
+ initWithMaximumDimension:maximumDimension
+ maximumPixelCount:maximumPixelCount];
+ self->_frameInterpolator.isEnabled = YES;
+ self->_frameInterpolationPaused = NO;
+ [oldInterpolator reset];
+
+ self->_queueSize = 8;
+ self->_needRequeuing = YES;
+ [self->_frameQueue setHighWaterMark:self->_queueSize];
+ [self->_frameQueue clear];
+ [self invalidateDecompressionSession];
+ [self restartDisplayLinkForInterpolationEnabled:YES];
+ LiRequestIdrFrame();
+
+ Log(LOG_I, @"Frame interpolation started or restarted with limits %ld / %ld pixels",
+ (long)maximumDimension, (long)maximumPixelCount);
+ });
+}
+
+- (void)stopFrameInterpolation {
+ if (self->_displayLink == nil || self->_renderingBackend != RENDER_AVSB) {
+ return;
+ }
+
+ DataManager *dataMan = [[DataManager alloc] init];
+ TemporarySettings *settings = [dataMan getSettings];
+ NSInteger normalQueueSize = settings.frameQueueSize.integerValue;
+
+ dispatch_async(self->_vtq, ^{
+ FrameInterpolator *oldInterpolator = self->_frameInterpolator;
+ self->_frameInterpolator = nil;
+ self->_frameInterpolationPaused = NO;
+ [oldInterpolator reset];
+
+ self->_queueSize = (int32_t)normalQueueSize;
+ self->_needRequeuing = self->_queueSize > 0;
+ [self->_frameQueue setHighWaterMark:MAX(1, self->_queueSize)];
+ [self->_frameQueue clear];
+ [self invalidateDecompressionSession];
+ [self restartDisplayLinkForInterpolationEnabled:NO];
+ LiRequestIdrFrame();
+
+ Log(LOG_I, @"Frame interpolation stopped");
+ });
+}
+
# pragma mark DisplayLink vsync callback
- (void)setupWithVideoFormat:(int)videoFormat width:(int)videoWidth height:(int)videoHeight frameRate:(int)frameRate fullRange:(BOOL)fullRange request10BitCodec:(BOOL)request10BitCodec
@@ -210,7 +345,9 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
_renderingBackend = RENDER_AVSB;
if (kEnableFrameInterpolation) {
- _frameInterpolator = [[FrameInterpolator alloc] init];
+ _frameInterpolator = [[FrameInterpolator alloc]
+ initWithMaximumDimension:settings.interpolationMaximumDimension.integerValue
+ maximumPixelCount:settings.interpolationMaximumPixelCount.integerValue];
_frameInterpolator.isEnabled = YES;
}
@@ -229,10 +366,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:displayLinkSelector];
- NSInteger targetFrameRate = self->_frameRate;
- if (self->_frameInterpolator != nil) {
- targetFrameRate = MIN(self->_frameRate * 2, self->_maxRefreshRate);
- }
+ NSInteger targetFrameRate = [self displayLinkFrameRateForInterpolationEnabled:self->_frameInterpolator != nil];
if (@available(iOS 15.0, tvOS 15.0, *)) {
_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(targetFrameRate, targetFrameRate, targetFrameRate);
@@ -289,61 +423,26 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
nativePixelFormat = self->_fullRange ? frFormat : vrFormat;
}
BOOL interpolationRequested = self->_frameInterpolator != nil;
- NSNumber *pixelFormat = interpolationRequested ? @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange) : nativePixelFormat;
+ OSType interpolationPixelFormat = 0;
if (interpolationRequested) {
- LogOnce(LOG_I, @"Frame interpolation enabled; requesting 420v output from VTDecompressionSession");
+ interpolationPixelFormat = [FrameInterpolator supportedPixelFormatClosestTo:nativePixelFormat.unsignedIntValue];
+ if (interpolationPixelFormat == 0) {
+ LogOnce(LOG_W, @"Frame interpolation disabled because the device reports no supported pixel format");
+ [self->_frameInterpolator reset];
+ self->_frameInterpolator = nil;
+ interpolationRequested = NO;
+ [self restartDisplayLinkForInterpolationEnabled:NO];
+ }
+ }
+ NSNumber *pixelFormat = interpolationRequested ? @(interpolationPixelFormat) : nativePixelFormat;
+ if (interpolationRequested) {
+ LogOnce(LOG_I, @"Frame interpolation enabled; requesting pixel format %@ from VTDecompressionSession", pixelFormat);
}
NSMutableDictionary *destinationPixelBufferAttributes = [@{
(id)kCVPixelBufferPixelFormatTypeKey : pixelFormat
} mutableCopy];
-#endif
-
-#if !TARGET_OS_SIMULATOR
if (interpolationRequested) {
- CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(_formatDesc);
- NSDictionary *interpolationSourceAttributes =
- [FrameInterpolator sourcePixelBufferAttributesForDimensions:dimensions];
- CFDictionaryRef resolvedAttributes = NULL;
- CVReturn resolveStatus = kCVReturnInvalidArgument;
-
- if (interpolationSourceAttributes != nil) {
- NSArray *attributeRequirements = @[
- destinationPixelBufferAttributes,
- interpolationSourceAttributes
- ];
- resolveStatus = CVPixelBufferCreateResolvedAttributesDictionary(
- kCFAllocatorDefault,
- (__bridge CFArrayRef)attributeRequirements,
- &resolvedAttributes);
- }
-
- if (resolveStatus == kCVReturnSuccess && resolvedAttributes != NULL) {
- destinationPixelBufferAttributes =
- [(__bridge NSDictionary *)resolvedAttributes mutableCopy];
- CFRelease(resolvedAttributes);
- } else {
- if (resolvedAttributes != NULL) {
- CFRelease(resolvedAttributes);
- }
- Log(LOG_W,
- @"Unable to resolve frame-interpolation source attributes for %dx%d (status %d); using native decode output",
- dimensions.width,
- dimensions.height,
- resolveStatus);
- [self->_frameInterpolator reset];
- self->_frameInterpolator = nil;
- interpolationRequested = NO;
- destinationPixelBufferAttributes = [@{
- (id)kCVPixelBufferPixelFormatTypeKey : nativePixelFormat
- } mutableCopy];
-
- NSInteger targetFrameRate = MIN(self->_frameRate, self->_maxRefreshRate);
- if (@available(iOS 15.0, tvOS 15.0, *)) {
- self->_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(targetFrameRate, targetFrameRate, targetFrameRate);
- } else {
- self->_displayLink.preferredFramesPerSecond = targetFrameRate;
- }
- }
+ destinationPixelBufferAttributes[(id)kCVPixelBufferIOSurfacePropertiesKey] = @{};
}
#endif
@@ -360,7 +459,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
OSStatus status = [self setupDecompressionSessionWithAttributes:destinationPixelBufferAttributes];
if (status != noErr) {
- Log(LOG_W, @"Falling back from 420v frame-interpolation decode output to native pixel format %@", nativePixelFormat);
+ Log(LOG_W, @"Falling back from frame-interpolation decode output %@ to native pixel format %@", pixelFormat, nativePixelFormat);
[self->_frameInterpolator reset];
self->_frameInterpolator = nil;
destinationPixelBufferAttributes = [@{
@@ -372,12 +471,7 @@ extern int ff_isom_write_av1c(AVIOContext *pb, const uint8_t *buf, int size,
}
[self setupDecompressionSessionWithAttributes:destinationPixelBufferAttributes];
- NSInteger targetFrameRate = MIN(self->_frameRate, self->_maxRefreshRate);
- if (@available(iOS 15.0, tvOS 15.0, *)) {
- self->_displayLink.preferredFrameRateRange = CAFrameRateRangeMake(targetFrameRate, targetFrameRate, targetFrameRate);
- } else {
- self->_displayLink.preferredFramesPerSecond = targetFrameRate;
- }
+ [self restartDisplayLinkForInterpolationEnabled:NO];
}
#else
[self setupDecompressionSessionWithAttributes:destinationPixelBufferAttributes];
@@ -574,6 +668,12 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
}
- (void)cleanup{
+ @synchronized([VideoDecoderRenderer class]) {
+ if (sActiveRenderer == self) {
+ sActiveRenderer = nil;
+ }
+ }
+
dispatch_async(dispatch_get_main_queue(), ^{
[self->_frameQueue stop];
@@ -1187,9 +1287,24 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
[frame setFormatDesc:self->_formatDesc];
}
- if (self->_frameInterpolator != nil) {
- [self->_frameInterpolator processFrame:frame completion:^(NSArray *frames) {
+ FrameInterpolator *interpolator = self->_frameInterpolator;
+ if (interpolator != nil && appDidEnterBackgroundWithoutPip) {
+ if (!self->_frameInterpolationPaused) {
+ self->_frameInterpolationPaused = YES;
+ [interpolator setPaused:YES];
+ }
+ }
+ else if (interpolator != nil) {
+ if (self->_frameInterpolationPaused) {
+ self->_frameInterpolationPaused = NO;
+ [interpolator setPaused:NO];
+ }
+ [interpolator processFrame:frame completion:^(NSArray *frames) {
dispatch_async(self->_vtq, ^{
+ if (self->_frameInterpolator != interpolator) {
+ return;
+ }
+
int framesDropped = 0;
for (Frame *outputFrame in (NSArray *)frames) {
framesDropped += [self->_frameQueue enqueue:outputFrame withSlackSize:3];
@@ -1403,22 +1518,7 @@ int DrSubmitDecodeUnit(PDECODE_UNIT decodeUnit);
- (void)resetFramePacing {
// Ensure this only runs for the AVSampleBuffer rendering backend and that the display link exists.
if (_renderingBackend == RENDER_AVSB && _displayLink) {
- Log(LOG_I, @"Frame pacing is being reset to %d FPS...", self->_frameRate);
-
- // Toggling the paused state can help re-engage the display link with the
- // run loop correctly after the app resumes from a background state like PiP.
- _displayLink.paused = YES;
-
- // Re-apply the desired frame rate range. This is the critical hint for ProMotion
- // that may have been lost or ignored during the PiP transition.
- if (@available(iOS 15.0, *)) {
- _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(self->_frameRate, self->_frameRate, self->_frameRate);
- } else {
- _displayLink.preferredFramesPerSecond = self->_frameRate;
- }
-
- // Resume the display link immediately.
- _displayLink.paused = NO;
+ [self restartDisplayLinkForInterpolationEnabled:self->_frameInterpolator.isEnabled];
} else if (_renderingBackend == RENDER_METAL) {
@synchronized(self) {
if (_decompressionSession != nil) {
diff --git a/VoidLink/Utility/GenericUtils.swift b/VoidLink/Utility/GenericUtils.swift
index 3160f760..2029873b 100644
--- a/VoidLink/Utility/GenericUtils.swift
+++ b/VoidLink/Utility/GenericUtils.swift
@@ -55,6 +55,23 @@ import UIKit
completion: {})
}
}
+
+ @objc public static func handleFrameInterpolationPixelFormatTip(in vc: UIViewController?) {
+ let key = "hasShownFrameInterpolationPixelFormatTip"
+ guard !UserDefaults.standard.bool(forKey: key) else {
+ return
+ }
+ UserDefaults.standard.set(true, forKey: key)
+
+ AlertControllerUtil.showAlert(
+ in: vc,
+ title: LocalizationHelper.localizedString(forKey: "Tips"),
+ message: "\n\(LocalizationHelper.localizedString(forKey: "Most devices currently support frame interpolation only for 8-bit YUV 4:2:0 SDR video. In this case, if HDR or YUV 4:4:4 is enabled, intermediate frames will still be generated in 8-bit YUV 4:2:0 SDR."))",
+ withCancel: false,
+ buttonTitle: LocalizationHelper.localizedString(forKey: "Got it!"),
+ countdown: 5
+ )
+ }
@objc public static func needUpdateDefaultSettings() -> Bool {
// let key = "needUpdateDefaultSettings20260226-1"
diff --git a/VoidLink/ViewControllers/MainFrameViewController.m b/VoidLink/ViewControllers/MainFrameViewController.m
index 412a91c3..99daca9c 100644
--- a/VoidLink/ViewControllers/MainFrameViewController.m
+++ b/VoidLink/ViewControllers/MainFrameViewController.m
@@ -852,8 +852,8 @@ static NSMutableSet* hostList;
}
}
- _streamConfig.height = [streamSettings.height intValue];
- _streamConfig.width = [streamSettings.width intValue];
+ _streamConfig.height = streamSettings.height.intValue;
+ _streamConfig.width = streamSettings.width.intValue;
NSLog(@"saveSettings.width %d, %d", _streamConfig.width, _streamConfig.height);
#if TARGET_OS_TV
@@ -1399,11 +1399,16 @@ static NSMutableSet* hostList;
[self.settingsViewController setHidden:_settingsExpandedInStreamView forStack:self.settingsViewController.appThemeStack];
[self.settingsViewController.renderingBackendSelector setEnabled:!_settingsExpandedInStreamView];
// Enable frame pacing mode selector only if not in stream view AND not in performance mode
- BOOL shouldEnableFramePacingSelector = !_settingsExpandedInStreamView && (self.settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL);
- [self.settingsViewController.framePacingModeSelector setEnabled:shouldEnableFramePacingSelector];
+ BOOL shouldEnableFrameQueueSettings = !_settingsExpandedInStreamView && (self.settingsViewController.renderingBackendSelector.selectedSegmentIndex != RENDER_METAL);
+ // [self.settingsViewController.framePacingModeSelector setEnabled:shouldEnableFrameQueueSettings];
+ [self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:0];
+ [self.settingsViewController.framePacingModeSelector setEnabled:!_settingsExpandedInStreamView forSegmentAtIndex:1];
+
// [self.settingsViewController.frameTimebaseSwitch setEnabled:shouldEnableFramePacing];
- [self.settingsViewController.asyncFrameDequeueSwitch setEnabled:shouldEnableFramePacingSelector];
- [self.settingsViewController setHidden:_settingsExpandedInStreamView || !(shouldEnableFramePacingSelector && self.settingsViewController.framePacingModeSelector.selectedSegmentIndex == FramePacingModeQueue) forStack:self.settingsViewController.frameQueueSizeStack];
+ [self.settingsViewController.asyncFrameDequeueSwitch setEnabled:shouldEnableFrameQueueSettings];
+ [self.settingsViewController setHidden:_settingsExpandedInStreamView || !(shouldEnableFrameQueueSettings && self.settingsViewController.framePacingModeSelector.selectedSegmentIndex == FramePacingModeQueue) forStack:self.settingsViewController.frameQueueSizeStack];
+ [self.settingsViewController setHidden:!(self.settingsViewController.framePacingModeSelector.selectedSegmentIndex == FramePacingModeInterpolation) forStack:self.settingsViewController.interpolationLevelStack];
+ [self.settingsViewController setHidden:_settingsExpandedInStreamView || !(self.settingsViewController.framePacingModeSelector.selectedSegmentIndex == FramePacingModeInterpolation) forStack:self.settingsViewController.streamDimensionScaleStack];
// Disable mic switch if sunshine does not support mic redirection
[self.settingsViewController.redirectMicSwitch setEnabled:!_settingsExpandedInStreamView||streamFrameViewController.micStreamInitialized];
@@ -2145,24 +2150,19 @@ static NSMutableSet* hostList;
.height = (int32_t)currentSettings.height.intValue,
};
[self fillResolutionTable:tempResolutionTable externalDisplayMode:currentSettings.externalDisplayMode.intValue];
-
+ return;
+
+ /*
int selectedIndex = currentSettings.resolutionSelected.intValue;
if (selectedIndex >= 0 && selectedIndex < 6) {
CMVideoDimensions originalDimensions = tempResolutionTable[selectedIndex];
-
- CMVideoDimensions targetDimensions;
- if(currentSettings.framePacingMode.intValue == FramePacingModeInterpolation) {
- targetDimensions = [FrameInterpolator interpolatableDimensionsBy:originalDimensions];
- if(!(targetDimensions.width>0 && targetDimensions.height>0)) targetDimensions = [FrameInterpolator interpolatableDimensionsBy720p:originalDimensions];
- }
- else targetDimensions = originalDimensions;
-
+ CMVideoDimensions targetDimensions = originalDimensions;
currentSettings.width = @(targetDimensions.width);
currentSettings.height = @(targetDimensions.height);
NSLog(@"Updated resolution to: %@ x %@", currentSettings.width, currentSettings.height);
}
-
[dataMan saveData];
+ */
}
#if TARGET_OS_TV
diff --git a/VoidLink/ViewControllers/SettingsViewController.h b/VoidLink/ViewControllers/SettingsViewController.h
index de512833..cfa029ef 100644
--- a/VoidLink/ViewControllers/SettingsViewController.h
+++ b/VoidLink/ViewControllers/SettingsViewController.h
@@ -128,6 +128,10 @@
@property (strong, nonatomic) IBOutlet UIStackView *graphOpacityStack;
@property (strong, nonatomic) IBOutlet UISegmentedControl *renderingBackendSelector;
@property (strong, nonatomic) IBOutlet UISegmentedControl *framePacingModeSelector;
+@property (strong, nonatomic) IBOutlet UIStackView *interpolationLevelStack;
+@property (strong, nonatomic) IBOutlet UISlider *interpolationLevelSlider;
+@property (strong, nonatomic) IBOutlet UIStackView *streamDimensionScaleStack;
+@property (strong, nonatomic) IBOutlet UISlider *streamDimensionScaleSlider;
@property (strong, nonatomic) IBOutlet UIStackView *backgroundSessionTimerStack;
@property (strong, nonatomic) IBOutlet UISlider *backgroundSessionTimerSlider;
diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m
index f40f9f24..19b89071 100644
--- a/VoidLink/ViewControllers/SettingsViewController.m
+++ b/VoidLink/ViewControllers/SettingsViewController.m
@@ -633,6 +633,12 @@ BOOL isCustomResolution(int resolutionSelected) {
if(!settingsViewAlreadyAppeared) {
if(![self contentOffsetRestored]) _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x, tempSettings.settingsMenuOffset.floatValue);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self.view layoutIfNeeded];
+ [self updateResolutionTable];
+ [self updateInterpolationLevelSliderWithMaximumDimension:self->tempSettings.interpolationMaximumDimension.integerValue];
+ [self.streamDimensionScaleSlider sendActionsForControlEvents:UIControlEventValueChanged];
+ });
}
if(@available(iOS 13.0, *)) if(ControllerUtil.primaryGCController) [self restoreControllerNavigationHighlight];
@@ -656,6 +662,13 @@ BOOL isCustomResolution(int resolutionSelected) {
bool unlockDisplayOrientationFlipped = tempSettings.unlockDisplayOrientation != (_unlockDisplayOrientationSelector.selectedSegmentIndex == 1);
if(unlockDisplayOrientationFlipped) [_mainFrameViewController setNeedsUpdateAllowedOrientation]; // handle allow portratit on & off
if (@available(iOS 13.0, *)) if(ControllerNavigator.radialMenuView.superview) [ControllerNavigator updateRadialMenu];
+
+ if(self.framePacingModeSelector.selectedSegmentIndex == FramePacingModeInterpolation) {
+ [VideoDecoderRenderer startOrRestartFrameInterpolation];
+ }
+ else {
+ [VideoDecoderRenderer stopFrameInterpolation];
+ }
}
@@ -884,6 +897,12 @@ BOOL isCustomResolution(int resolutionSelected) {
[self addSetting:self.framePacingStack ofId:@"framePacingStack" to:videoSection];
if(!FrameInterpolator.deviceSupportsInterpolation) [self.framerateSelector setEnabled:false forSegmentAtIndex:FramePacingModeInterpolation];
+ self.interpolationLevelStack.hasDynamicLabel = YES;
+ [self addSetting:self.interpolationLevelStack ofId:@"interpolationLevelStack" to:videoSection];
+
+ self.streamDimensionScaleStack.hasDynamicLabel = YES;
+ [self addSetting:self.streamDimensionScaleStack ofId:@"streamDimensionScaleStack" to:videoSection];
+
self.frameQueueSizeStack.hasDynamicLabel = YES;
[self addSetting:self.frameQueueSizeStack ofId:@"frameQueueSizeStack" to:videoSection];
@@ -2235,6 +2254,12 @@ BOOL isCustomResolution(int resolutionSelected) {
NSInteger framePacingMode = [self->tempSettings.framePacingMode integerValue];
self.framePacingModeSelector.selectedSegmentIndex = framePacingMode;
+ ((UILabel *)self.interpolationLevelStack.arrangedSubviews.firstObject).text =
+ [LocalizationHelper localizedStringForKey:@"Interpolation Resolution"];
+ [self.interpolationLevelSlider addTarget:self action:@selector(interpolationLevelSliderMoved:) forControlEvents:UIControlEventValueChanged];
+ ((UILabel *)self.streamDimensionScaleStack.arrangedSubviews.firstObject).text =
+ [LocalizationHelper localizedStringForKey:@"Scaled Stream Resolution"];
+ [self.streamDimensionScaleSlider addTarget:self action:@selector(streamDimensionScaleSliderMoved:) forControlEvents:UIControlEventValueChanged];
[self.framePacingModeSelector addTarget:self action:@selector(framePacingModeChanged:) forControlEvents:UIControlEventValueChanged];
[self.framePacingModeSelector sendActionsForControlEvents:UIControlEventValueChanged];
@@ -2311,6 +2336,11 @@ BOOL isCustomResolution(int resolutionSelected) {
self->_lastSelectedResolutionIndex = resolution;
self.resolutionSelector.selectedSegmentIndex = resolution;
+ [self updateInterpolationLevelSliderWithMaximumDimension:self->tempSettings.interpolationMaximumDimension.integerValue];
+ self.streamDimensionScaleSlider.minimumValue = 0;
+ self.streamDimensionScaleSlider.maximumValue = 1;
+ self.streamDimensionScaleSlider.value = MIN(1, MAX(0, self->tempSettings.streamDimensionScale.floatValue));
+ [self.streamDimensionScaleSlider sendActionsForControlEvents:UIControlEventValueChanged];
[self.resolutionSelector addTarget:self action:@selector(newResolutionChosen) forControlEvents:UIControlEventValueChanged];
self.framerateSelector.selectedSegmentIndex = framerate;
@@ -2690,10 +2720,11 @@ BOOL isCustomResolution(int resolutionSelected) {
- (void)framePacingModeChanged:(UISegmentedControl *)sender {
// Hide frame queue size for Off and Legacy modes
[GenericUtils handleLegacyFramePacingTipIn:self with:sender passAlert:settingsViewJustExpanded uiAction:^{
- [self setHidden:(sender.selectedSegmentIndex == FramePacingModeOff || sender.selectedSegmentIndex == FramePacingModeLegacy) forStack:self.frameQueueSizeStack];
- // [self setHidden:(sender.selectedSegmentIndex != FramePacingModeQueue) forStack:self.frameTimebaseStack];
[self setHidden:(sender.selectedSegmentIndex != FramePacingModeQueue
- || sender.selectedSegmentIndex != FramePacingModeInterpolation
+ || self->_mainFrameViewController.isStreaming) forStack:self.frameQueueSizeStack];
+ // [self setHidden:(sender.selectedSegmentIndex != FramePacingModeQueue) forStack:self.frameTimebaseStack];
+ [self setHidden:((sender.selectedSegmentIndex != FramePacingModeQueue
+ && sender.selectedSegmentIndex != FramePacingModeInterpolation)
|| self.renderingBackendSelector.selectedSegmentIndex != 0) forStack:self.asyncFrameDequeueStack];
if(sender.selectedSegmentIndex == FramePacingModeOff || sender.selectedSegmentIndex == FramePacingModeLegacy){
@@ -2705,6 +2736,8 @@ BOOL isCustomResolution(int resolutionSelected) {
[self setHidden:(sender.selectedSegmentIndex == FramePacingModeOff || sender.selectedSegmentIndex == FramePacingModeLegacy) forStack:self.performanceGraphStack];
bool enableInterpolation = sender.selectedSegmentIndex == FramePacingModeInterpolation;
+ [self setHidden:!enableInterpolation forStack:self.interpolationLevelStack];
+ [self setHidden:!enableInterpolation || self->_mainFrameViewController.isStreaming forStack:self.streamDimensionScaleStack];
[self widget:self.frameQueueSizeSlider setEnabled:!enableInterpolation];
[self updateResolutionDisplayLabel];
if(enableInterpolation){
@@ -2712,6 +2745,9 @@ BOOL isCustomResolution(int resolutionSelected) {
if(!FrameInterpolator.dimensionLimitsAreKnown){
NSLog(@"FrameInterpolator dimensionLimitsUnknown");
}
+ if(!self->settingsViewJustExpanded){
+ [GenericUtils handleFrameInterpolationPixelFormatTipIn:self];
+ }
}
}];
@@ -3663,7 +3699,7 @@ BOOL isCustomResolution(int resolutionSelected) {
[[NSUserDefaults standardUserDefaults] setBool:YES forKey:key];
[[NSUserDefaults standardUserDefaults] synchronize];
- if(self.framerateSelector.selectedSegmentIndex == 2
+ if(self.framerateSelector.selectedSegmentIndex == self.framerateSelector.numberOfSegments - 1
&& self.framePacingModeSelector.selectedSegmentIndex == FramePacingModeInterpolation) self.framerateSelector.selectedSegmentIndex = self.framerateSelector.previousSelectedSegmentIndex;
// NSInteger fps = [self getChosenFrameRate];
@@ -3735,10 +3771,13 @@ BOOL isCustomResolution(int resolutionSelected) {
}
- (void) newResolutionChosen {
- [self updateBitrate];
- [self updateResolutionDisplayLabel];
_lastSelectedResolutionIndex = self.resolutionSelector.selectedSegmentIndex;
- [self updateResolutionTable];
+ if (self.mainFrameViewController.settingsExpandedInStreamView) {
+ [self updateResolutionDisplayLabel];
+ } else {
+ [self updateResolutionTable];
+ }
+ [self updateBitrate];
}
- (void)customResolutionSwitched:(UISwitch* )sender{
@@ -3836,11 +3875,44 @@ BOOL isCustomResolution(int resolutionSelected) {
}
- (void) updateResolutionDisplayLabel {
- CMVideoDimensions dimensions = [self getChosenStreamDimensions];
+ CMVideoDimensions dimensions = [self getChosenPresetStreamDimensions];
NSInteger width = dimensions.width;
NSInteger height = dimensions.height;
[self findDynamicLabelFromStack:_resolutionSelectorStack].text = [NSString stringWithFormat:@"%ld × %ld", (long)width, (long)height];
+ if (self.interpolationLevelSlider != nil) {
+ [self.interpolationLevelSlider sendActionsForControlEvents:UIControlEventValueChanged];
+ }
+}
+
+- (void)updateInterpolationLevelSliderWithMaximumDimension:(NSInteger)savedMaximumDimension {
+ self.interpolationLevelSlider.minimumValue = 0;
+ self.interpolationLevelSlider.maximumValue = 1;
+ self.interpolationLevelSlider.value = [FrameInterpolator
+ interpolationLevelForDimensions:[self getChosenPresetStreamDimensions]
+ savedMaximumDimension:savedMaximumDimension];
+ [self.interpolationLevelSlider sendActionsForControlEvents:UIControlEventValueChanged];
+}
+
+- (void)interpolationLevelSliderMoved:(UISlider *)sender {
+ InterpolationResolutionConfiguration *configuration = [self getCurrentInterpolationResolutionConfiguration];
+ [self findDynamicLabelFromStack:self.interpolationLevelStack].text =
+ [NSString stringWithFormat:@" %d × %d ", configuration.dimensions.width, configuration.dimensions.height];
+ if (self.streamDimensionScaleSlider != nil) {
+ [self.streamDimensionScaleSlider sendActionsForControlEvents:UIControlEventValueChanged];
+ }
+}
+
+- (InterpolationResolutionConfiguration *)getCurrentInterpolationResolutionConfiguration {
+ return [FrameInterpolator
+ resolutionConfigurationForDimensions:[self getChosenPresetStreamDimensions]
+ level:self.interpolationLevelSlider.value];
+}
+
+- (void)streamDimensionScaleSliderMoved:(UISlider *)sender {
+ CMVideoDimensions dimensions = [self getChosenStreamDimensions];
+ [self findDynamicLabelFromStack:self.streamDimensionScaleStack].text =
+ [NSString stringWithFormat:@" %d × %d ", dimensions.width, dimensions.height];
}
- (void) touchMoveEventIntervalSliderMoved:(UISlider* )sender{
@@ -3911,7 +3983,7 @@ BOOL isCustomResolution(int resolutionSelected) {
}
}
-- (CMVideoDimensions)getChosenStreamDimensions {
+- (CMVideoDimensions)getChosenPresetStreamDimensions {
// because the 4k resolution can be removed
CMVideoDimensions originalDimensions;
if (self.customResolutionSwitch.isOn) {
@@ -3920,6 +3992,12 @@ BOOL isCustomResolution(int resolutionSelected) {
originalDimensions = resolutionTable[self.resolutionSelector.selectedSegmentIndex];
}
+ if(originalDimensions.width == 0 || originalDimensions.height == 0) {
+ originalDimensions.width = tempSettings.width.intValue;
+ originalDimensions.height = tempSettings.height.intValue;
+ }
+
+ /*
CMVideoDimensions targetDimensions = self.framePacingModeSelector.selectedSegmentIndex == FramePacingModeInterpolation
? [FrameInterpolator interpolatableDimensionsBy:originalDimensions]
: originalDimensions;
@@ -3929,8 +4007,25 @@ BOOL isCustomResolution(int resolutionSelected) {
}
NSLog(@"dimensions: %d, %d", targetDimensions.width, targetDimensions.height);
+ */
+
+ return originalDimensions;
+}
- return targetDimensions;
+- (CMVideoDimensions)getChosenStreamDimensions {
+ CMVideoDimensions presetDimensions = [self getChosenPresetStreamDimensions];
+ if (self.streamDimensionScaleSlider == nil) {
+ return [FrameInterpolator
+ scaledStreamDimensionsWithPresetDimensions:presetDimensions
+ interpolationDimensions:presetDimensions
+ scale:1];
+ }
+
+ InterpolationResolutionConfiguration *configuration = [self getCurrentInterpolationResolutionConfiguration];
+ return [FrameInterpolator
+ scaledStreamDimensionsWithPresetDimensions:presetDimensions
+ interpolationDimensions:configuration.dimensions
+ scale:self.streamDimensionScaleSlider.value];
}
- (UIStackView *)findFlatStackViewFrom:(UIView *)view {
@@ -4299,6 +4394,11 @@ BOOL isCustomResolution(int resolutionSelected) {
NSInteger renderingBackend = self.renderingBackendSelector.selectedSegmentIndex;
NSInteger framePacingMode = self.framePacingModeSelector.selectedSegmentIndex;
+ InterpolationResolutionConfiguration *interpolationConfiguration =
+ [self getCurrentInterpolationResolutionConfiguration];
+ NSInteger interpolationMaximumDimension = interpolationConfiguration.maximumDimension;
+ NSInteger interpolationMaximumPixelCount = interpolationConfiguration.maximumPixelCount;
+ CGFloat streamDimensionScale = self.streamDimensionScaleSlider.value;
NSInteger onscreenControls = self.onScreenWidgetSelector.selectedSegmentIndex;
NSInteger keyboardToggleFingers = self.softKeyboardGestureSelector.selectedSegmentIndex == 3 ? 20 : self.softKeyboardGestureSelector.selectedSegmentIndex+3;
NSInteger oscLayoutToolFingers = (uint16_t)self->oswLayoutFingers;
@@ -4428,6 +4528,9 @@ BOOL isCustomResolution(int resolutionSelected) {
graphOpacity:graphOpacity
renderingBackend:renderingBackend
framePacingMode:framePacingMode
+ interpolationMaximumDimension:interpolationMaximumDimension
+ interpolationMaximumPixelCount:interpolationMaximumPixelCount
+ streamDimensionScale:streamDimensionScale
sendDummyEvent:sendDummyEvent
rememberFoldState:rememberFoldState
singleTapSensitivy:singleTapSensitivity
@@ -4466,7 +4569,6 @@ BOOL isCustomResolution(int resolutionSelected) {
}];
});
}
-
}
- (void)didReceiveMemoryWarning {
diff --git a/VoidLink/mul.lproj/iPad.xcstrings b/VoidLink/mul.lproj/iPad.xcstrings
index c2c964ea..2067037b 100644
--- a/VoidLink/mul.lproj/iPad.xcstrings
+++ b/VoidLink/mul.lproj/iPad.xcstrings
@@ -3056,6 +3056,30 @@
}
}
},
+ "FIp-Lv-Lbl.text" : {
+ "comment" : "Class = \"UILabel\"; text = \"Interpolation Resolution\"; ObjectID = \"FIp-Lv-Lbl\";",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Interpolation Resolution"
+ }
+ }
+ }
+ },
+ "FIp-Sc-Lbl.text" : {
+ "comment" : "Class = \"UILabel\"; text = \"Scaled Stream Resolution\"; ObjectID = \"FIp-Sc-Lbl\";",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Scaled Stream Resolution"
+ }
+ }
+ }
+ },
"FJv-HJ-UYJ.text" : {
"comment" : "Class = \"UILabel\"; text = \"Touch Move Event Interval\"; ObjectID = \"FJv-HJ-UYJ\";",
"extractionState" : "extracted_with_value",
diff --git a/VoidLink/mul.lproj/iPhone.xcstrings b/VoidLink/mul.lproj/iPhone.xcstrings
index 3c649beb..630bd2ca 100644
--- a/VoidLink/mul.lproj/iPhone.xcstrings
+++ b/VoidLink/mul.lproj/iPhone.xcstrings
@@ -2557,6 +2557,30 @@
}
}
},
+ "FIi-Lv-Lbl.text" : {
+ "comment" : "Class = \"UILabel\"; text = \"Interpolation Resolution\"; ObjectID = \"FIi-Lv-Lbl\";",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Interpolation Resolution"
+ }
+ }
+ }
+ },
+ "FIi-Sc-Lbl.text" : {
+ "comment" : "Class = \"UILabel\"; text = \"Scaled Stream Resolution\"; ObjectID = \"FIi-Sc-Lbl\";",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Scaled Stream Resolution"
+ }
+ }
+ }
+ },
"fRl-Ra-Lbl.text" : {
"comment" : "Class = \"UILabel\"; text = \"Full Color Range\"; ObjectID = \"fRl-Ra-Lbl\";",
"extractionState" : "extracted_with_value",