diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index a5bb0085..0b5a25df 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -6,7 +6,7 @@ on: jobs: unit-tests: name: Run Unit Tests - runs-on: macos-13 + runs-on: macos-14 steps: - name: Install xcbeautify run: brew install xcbeautify diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/project.pbxproj b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/project.pbxproj index b94c257d..bd7f0095 100644 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/project.pbxproj +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/project.pbxproj @@ -9,6 +9,11 @@ /* Begin PBXBuildFile section */ 191C59C42A69C9A900D3AF05 /* SwiftUploadSDKExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A7B2A4CA6A300CFA822 /* SwiftUploadSDKExampleUITests.swift */; }; 191C59C52A69C9AC00D3AF05 /* SwiftUploadSDKExampleLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A7C2A4CA6A300CFA822 /* SwiftUploadSDKExampleLaunchTests.swift */; }; + 197DD9DA2C0E428C002C1294 /* UploadListPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197DD9D92C0E428C002C1294 /* UploadListPlaceholderView.swift */; }; + 197DD9DC2C0E7003002C1294 /* SelectVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197DD9DB2C0E7003002C1294 /* SelectVideoView.swift */; }; + 197DD9DE2C0E7161002C1294 /* ThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197DD9DD2C0E7161002C1294 /* ThumbnailView.swift */; }; + 197DD9E02C0E71A1002C1294 /* ProcessingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197DD9DF2C0E71A1002C1294 /* ProcessingView.swift */; }; + 197DD9E22C0E71C6002C1294 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 197DD9E12C0E71C6002C1294 /* ErrorView.swift */; }; 19822A692A4CA69700CFA822 /* Mux Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 19822A522A4CA69700CFA822 /* Mux Assets.xcassets */; }; 19822A6A2A4CA69700CFA822 /* Buttons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A542A4CA69700CFA822 /* Buttons.swift */; }; 19822A6B2A4CA69700CFA822 /* MuxNavBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A552A4CA69700CFA822 /* MuxNavBar.swift */; }; @@ -23,10 +28,8 @@ 19822A742A4CA69700CFA822 /* UploadListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A622A4CA69700CFA822 /* UploadListModel.swift */; }; 19822A752A4CA69700CFA822 /* UploadCreationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A632A4CA69700CFA822 /* UploadCreationModel.swift */; }; 19822A762A4CA69700CFA822 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A642A4CA69700CFA822 /* ContentView.swift */; }; - 19822A772A4CA69700CFA822 /* UploadCTA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A662A4CA69700CFA822 /* UploadCTA.swift */; }; - 19822A792A4CA69700CFA822 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A682A4CA69700CFA822 /* ImagePicker.swift */; }; + 19822A772A4CA69700CFA822 /* UploadCallToActionLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19822A662A4CA69700CFA822 /* UploadCallToActionLabel.swift */; }; 19DCD95B2A4CA567001FBBF6 /* MuxUploadSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 19DCD95A2A4CA567001FBBF6 /* MuxUploadSDK */; }; - F38876D22B86DCFB00B82A86 /* MuxUploadSDK in Frameworks */ = {isa = PBXBuildFile; productRef = F38876D12B86DCFB00B82A86 /* MuxUploadSDK */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -37,16 +40,14 @@ remoteGlobalIDString = 358E3C7629A92167005261CB; remoteInfo = "Test App"; }; - F38876CB2B86DBEB00B82A86 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 358E3C6F29A92167005261CB /* Project object */; - proxyType = 1; - remoteGlobalIDString = 358E3C7629A92167005261CB; - remoteInfo = SwiftUploadSDKExample; - }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 197DD9D92C0E428C002C1294 /* UploadListPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadListPlaceholderView.swift; sourceTree = ""; }; + 197DD9DB2C0E7003002C1294 /* SelectVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectVideoView.swift; sourceTree = ""; }; + 197DD9DD2C0E7161002C1294 /* ThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailView.swift; sourceTree = ""; }; + 197DD9DF2C0E71A1002C1294 /* ProcessingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessingView.swift; sourceTree = ""; }; + 197DD9E12C0E71C6002C1294 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 19822A522A4CA69700CFA822 /* Mux Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Mux Assets.xcassets"; sourceTree = ""; }; 19822A542A4CA69700CFA822 /* Buttons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Buttons.swift; sourceTree = ""; }; 19822A552A4CA69700CFA822 /* MuxNavBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MuxNavBar.swift; sourceTree = ""; }; @@ -61,14 +62,12 @@ 19822A622A4CA69700CFA822 /* UploadListModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UploadListModel.swift; sourceTree = ""; }; 19822A632A4CA69700CFA822 /* UploadCreationModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UploadCreationModel.swift; sourceTree = ""; }; 19822A642A4CA69700CFA822 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 19822A662A4CA69700CFA822 /* UploadCTA.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UploadCTA.swift; sourceTree = ""; }; - 19822A682A4CA69700CFA822 /* ImagePicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; + 19822A662A4CA69700CFA822 /* UploadCallToActionLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UploadCallToActionLabel.swift; sourceTree = ""; }; 19822A7B2A4CA6A300CFA822 /* SwiftUploadSDKExampleUITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUploadSDKExampleUITests.swift; sourceTree = ""; }; 19822A7C2A4CA6A300CFA822 /* SwiftUploadSDKExampleLaunchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUploadSDKExampleLaunchTests.swift; sourceTree = ""; }; 19DCD9592A4CA546001FBBF6 /* swift-upload-sdk */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "swift-upload-sdk"; path = ../..; sourceTree = ""; }; 358E3C7729A92167005261CB /* SwiftUploadSDKExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUploadSDKExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 358E3C9129A92168005261CB /* SwiftUploadSDKExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SwiftUploadSDKExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - F38876C72B86DBEB00B82A86 /* .xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = .xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -87,14 +86,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F38876C42B86DBEB00B82A86 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - F38876D22B86DCFB00B82A86 /* MuxUploadSDK in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -104,12 +95,10 @@ 19822A512A4CA69700CFA822 /* Mux Style */, 19822A572A4CA69700CFA822 /* Fake Server Backend */, 19822A592A4CA69700CFA822 /* Assets.xcassets */, - 19822A5A2A4CA69700CFA822 /* Preview Content */, - 19822A5C2A4CA69700CFA822 /* Screens */, 19822A5F2A4CA69700CFA822 /* SwiftUploadSDKExample.swift */, + 19822A5A2A4CA69700CFA822 /* Preview Content */, + 19822A5C2A4CA69700CFA822 /* Views */, 19822A602A4CA69700CFA822 /* Model */, - 19822A642A4CA69700CFA822 /* ContentView.swift */, - 19822A652A4CA69700CFA822 /* Widgets */, ); path = SwiftUploadSDKExample; sourceTree = ""; @@ -149,13 +138,20 @@ path = "Preview Content"; sourceTree = ""; }; - 19822A5C2A4CA69700CFA822 /* Screens */ = { + 19822A5C2A4CA69700CFA822 /* Views */ = { isa = PBXGroup; children = ( + 19822A642A4CA69700CFA822 /* ContentView.swift */, 19822A5D2A4CA69700CFA822 /* CreateUploadView.swift */, + 197DD9E12C0E71C6002C1294 /* ErrorView.swift */, 19822A5E2A4CA69700CFA822 /* UploadListView.swift */, + 19822A662A4CA69700CFA822 /* UploadCallToActionLabel.swift */, + 197DD9D92C0E428C002C1294 /* UploadListPlaceholderView.swift */, + 197DD9DB2C0E7003002C1294 /* SelectVideoView.swift */, + 197DD9DD2C0E7161002C1294 /* ThumbnailView.swift */, + 197DD9DF2C0E71A1002C1294 /* ProcessingView.swift */, ); - path = Screens; + path = Views; sourceTree = ""; }; 19822A602A4CA69700CFA822 /* Model */ = { @@ -168,15 +164,6 @@ path = Model; sourceTree = ""; }; - 19822A652A4CA69700CFA822 /* Widgets */ = { - isa = PBXGroup; - children = ( - 19822A662A4CA69700CFA822 /* UploadCTA.swift */, - 19822A682A4CA69700CFA822 /* ImagePicker.swift */, - ); - path = Widgets; - sourceTree = ""; - }; 19822A7A2A4CA6A300CFA822 /* SwiftUploadSDKExampleTests */ = { isa = PBXGroup; children = ( @@ -192,7 +179,6 @@ 358E3CC329A9221F005261CB /* Packages */, 19822A502A4CA69700CFA822 /* SwiftUploadSDKExample */, 19822A7A2A4CA6A300CFA822 /* SwiftUploadSDKExampleTests */, - F38876C82B86DBEB00B82A86 /* SwiftUploadSDKExampleUnitTests */, 358E3C7829A92167005261CB /* Products */, 358E3CC529A9223B005261CB /* Frameworks */, ); @@ -203,7 +189,6 @@ children = ( 358E3C7729A92167005261CB /* SwiftUploadSDKExample.app */, 358E3C9129A92168005261CB /* SwiftUploadSDKExampleTests.xctest */, - F38876C72B86DBEB00B82A86 /* .xctest */, ); name = Products; sourceTree = ""; @@ -223,13 +208,6 @@ name = Frameworks; sourceTree = ""; }; - F38876C82B86DBEB00B82A86 /* SwiftUploadSDKExampleUnitTests */ = { - isa = PBXGroup; - children = ( - ); - path = SwiftUploadSDKExampleUnitTests; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -271,27 +249,6 @@ productReference = 358E3C9129A92168005261CB /* SwiftUploadSDKExampleTests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; - F38876C62B86DBEB00B82A86 /* SwiftUploadSDKExampleUnitTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = F38876CD2B86DBEB00B82A86 /* Build configuration list for PBXNativeTarget "SwiftUploadSDKExampleUnitTests" */; - buildPhases = ( - F38876C32B86DBEB00B82A86 /* Sources */, - F38876C42B86DBEB00B82A86 /* Frameworks */, - F38876C52B86DBEB00B82A86 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - F38876CC2B86DBEB00B82A86 /* PBXTargetDependency */, - ); - name = SwiftUploadSDKExampleUnitTests; - packageProductDependencies = ( - F38876D12B86DCFB00B82A86 /* MuxUploadSDK */, - ); - productName = SwiftUploadSDKExampleUnitTests; - productReference = F38876C72B86DBEB00B82A86 /* .xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -299,7 +256,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1500; + LastSwiftUpdateCheck = 1420; LastUpgradeCheck = 1420; TargetAttributes = { 358E3C7629A92167005261CB = { @@ -309,10 +266,6 @@ CreatedOnToolsVersion = 14.2; TestTargetID = 358E3C7629A92167005261CB; }; - F38876C62B86DBEB00B82A86 = { - CreatedOnToolsVersion = 15.0.1; - TestTargetID = 358E3C7629A92167005261CB; - }; }; }; buildConfigurationList = 358E3C7229A92167005261CB /* Build configuration list for PBXProject "SwiftUploadSDKExample" */; @@ -324,16 +277,12 @@ Base, ); mainGroup = 358E3C6E29A92167005261CB; - packageReferences = ( - F38876D02B86DCFB00B82A86 /* XCRemoteSwiftPackageReference "swift-upload-sdk" */, - ); productRefGroup = 358E3C7829A92167005261CB /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 358E3C7629A92167005261CB /* SwiftUploadSDKExample */, 358E3C9029A92168005261CB /* SwiftUploadSDKExampleTests */, - F38876C62B86DBEB00B82A86 /* SwiftUploadSDKExampleUnitTests */, ); }; /* End PBXProject section */ @@ -356,13 +305,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F38876C52B86DBEB00B82A86 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -370,19 +312,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 197DD9DC2C0E7003002C1294 /* SelectVideoView.swift in Sources */, 19822A702A4CA69700CFA822 /* CreateUploadView.swift in Sources */, 19822A762A4CA69700CFA822 /* ContentView.swift in Sources */, 19822A722A4CA69700CFA822 /* SwiftUploadSDKExample.swift in Sources */, - 19822A792A4CA69700CFA822 /* ImagePicker.swift in Sources */, + 197DD9E22C0E71C6002C1294 /* ErrorView.swift in Sources */, 19822A6C2A4CA69700CFA822 /* Mux Colors.swift in Sources */, + 197DD9DA2C0E428C002C1294 /* UploadListPlaceholderView.swift in Sources */, 19822A752A4CA69700CFA822 /* UploadCreationModel.swift in Sources */, + 197DD9E02C0E71A1002C1294 /* ProcessingView.swift in Sources */, 19822A6B2A4CA69700CFA822 /* MuxNavBar.swift in Sources */, 19822A732A4CA69700CFA822 /* ThumbnailModel.swift in Sources */, 19822A6A2A4CA69700CFA822 /* Buttons.swift in Sources */, 19822A6D2A4CA69700CFA822 /* FakeBackend.swift in Sources */, 19822A712A4CA69700CFA822 /* UploadListView.swift in Sources */, + 197DD9DE2C0E7161002C1294 /* ThumbnailView.swift in Sources */, 19822A742A4CA69700CFA822 /* UploadListModel.swift in Sources */, - 19822A772A4CA69700CFA822 /* UploadCTA.swift in Sources */, + 19822A772A4CA69700CFA822 /* UploadCallToActionLabel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -395,13 +341,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - F38876C32B86DBEB00B82A86 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -410,11 +349,6 @@ target = 358E3C7629A92167005261CB /* SwiftUploadSDKExample */; targetProxy = 358E3C9229A92168005261CB /* PBXContainerItemProxy */; }; - F38876CC2B86DBEB00B82A86 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 358E3C7629A92167005261CB /* SwiftUploadSDKExample */; - targetProxy = F38876CB2B86DBEB00B82A86 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -549,12 +483,13 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mux.video.upload.Test-App"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -580,12 +515,13 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mux.video.upload.Test-App"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; @@ -600,9 +536,10 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = XX95P4Y787; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mux.video.upload.Test-AppUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -617,9 +554,10 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = XX95P4Y787; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.mux.video.upload.Test-AppUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; @@ -628,49 +566,6 @@ }; name = Release; }; - F38876CE2B86DBEB00B82A86 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUploadSDKExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SwiftUploadSDKExample"; - }; - name = Debug; - }; - F38876CF2B86DBEB00B82A86 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SwiftUploadSDKExample.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SwiftUploadSDKExample"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -701,38 +596,13 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F38876CD2B86DBEB00B82A86 /* Build configuration list for PBXNativeTarget "SwiftUploadSDKExampleUnitTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F38876CE2B86DBEB00B82A86 /* Debug */, - F38876CF2B86DBEB00B82A86 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - F38876D02B86DCFB00B82A86 /* XCRemoteSwiftPackageReference "swift-upload-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/muxinc/swift-upload-sdk"; - requirement = { - branch = main; - kind = branch; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ 19DCD95A2A4CA567001FBBF6 /* MuxUploadSDK */ = { isa = XCSwiftPackageProductDependency; productName = MuxUploadSDK; }; - F38876D12B86DCFB00B82A86 /* MuxUploadSDK */ = { - isa = XCSwiftPackageProductDependency; - package = F38876D02B86DCFB00B82A86 /* XCRemoteSwiftPackageReference "swift-upload-sdk" */; - productName = MuxUploadSDK; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 358E3C6F29A92167005261CB /* Project object */; diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bb40ae73..e65252d0 100644 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-docc-plugin", "state" : { - "revision" : "9b1258905c21fc1b97bf03d1b4ca12c4ec4e5fda", - "version" : "1.2.0" + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", + "version" : "1.3.0" } }, { diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/xcshareddata/xcschemes/SwiftUploadSDKExample.xcscheme b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/xcshareddata/xcschemes/SwiftUploadSDKExample.xcscheme index da92a748..ab894997 100644 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/xcshareddata/xcschemes/SwiftUploadSDKExample.xcscheme +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample.xcodeproj/xcshareddata/xcschemes/SwiftUploadSDKExample.xcscheme @@ -40,17 +40,6 @@ ReferencedContainer = "container:SwiftUploadSDKExample.xcodeproj"> - - - - ? = nil + private var thumbnailGenerator: AVAssetImageGenerator? = nil + + private let logger = SwiftUploadSDKExample.logger + private let myServerBackend = FakeBackend(urlSession: URLSession(configuration: URLSessionConfiguration.default)) + + @Published var photosAuthStatus: PhotosAuthState + @Published var exportState: ExportState = .not_started + @Published var pickedItem: [PhotosPickerItem] = [] { + didSet { + tryToPrepare(from: pickedItem.first!) } } + + init() { + let innerAuthStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + self.photosAuthStatus = innerAuthStatus.asAppAuthState() + self.exportState = .not_started + } @discardableResult func startUpload(preparedMedia: PreparedUpload, forceRestart: Bool) -> DirectUpload { let upload = DirectUpload( uploadURL: preparedMedia.remoteURL, - videoFileURL: preparedMedia.localVideoFile + inputAsset: AVAsset(url: preparedMedia.localVideoFile), + options: .default ) upload.progressHandler = { progress in self.logger.info("Uploading \(progress.progress?.completedUnitCount ?? 0)/\(progress.progress?.totalUnitCount ?? 0)") @@ -55,59 +83,67 @@ class UploadCreationModel : ObservableObject { } /// Prepares a Photos Asset for upload by exporting it to a local temp file - func tryToPrepare(from pickerResult: PHPickerResult) { - if case ExportState.preparing = exportState { - return - } - - // Cancel anything that was already happening - if let assetRequestId = assetRequestId { - PHImageManager.default().cancelImageRequest(assetRequestId) - } - if let prepareTask = prepareTask { - prepareTask.cancel() - } - if let thumbnailGenerator = thumbnailGenerator { - thumbnailGenerator.cancelAllCGImageGeneration() - } - - // TODO: This is a very common workflow. Should the SDK be able to do this workflow with Photos? + func tryToPrepare(from pickerItem: PhotosPickerItem) { exportState = .preparing let tempDir = FileManager.default.temporaryDirectory - let tempFile = URL(string: "upload-\(Date().timeIntervalSince1970).mp4", relativeTo: tempDir)! - - guard let assetIdentitfier = pickerResult.assetIdentifier else { - NSLog("!! No Asset ID for chosen asset") - exportState = .failure(UploadCreationModel.PickerError.assetExportSessionFailed) - return - } - let options = PHFetchOptions() - options.includeAssetSourceTypes = [.typeUserLibrary, .typeCloudShared] - let phAssetResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetIdentitfier], options: options) - guard let phAsset = phAssetResult.firstObject else { - self.logger.error("!! No Asset fetched") + let tempFile = URL( + string: "upload-\(Date().timeIntervalSince1970).mp4", + relativeTo: tempDir + )! + + guard let itemIdentifier = pickerItem.itemIdentifier else { + self.logger.error("No item identifier for chosen video") Task.detached { await MainActor.run { - self.exportState = .failure(PickerError.missingAssetIdentifier) + self.exportState = .failure( + PickerError.assetExportSessionFailed + ) } } return } - - let exportOptions = PHVideoRequestOptions() - exportOptions.isNetworkAccessAllowed = true - exportOptions.deliveryMode = .highQualityFormat - assetRequestId = PHImageManager.default().requestExportSession(forVideo: phAsset, options: exportOptions, exportPreset: AVAssetExportPresetHighestQuality, resultHandler: {(exportSession, info) -> Void in - DispatchQueue.main.async { - guard let exportSession = exportSession else { - self.logger.error("!! No Export session") - self.exportState = .failure(UploadCreationModel.PickerError.assetExportSessionFailed) - return + + doRequestPhotosPermission { authorizationStatus in + Task.detached { + await MainActor.run { + self.photosAuthStatus = authorizationStatus.asAppAuthState() + + let options = PHFetchOptions() + options.includeAssetSourceTypes = [.typeUserLibrary, .typeCloudShared] + let fetchAssetResult = PHAsset.fetchAssets(withLocalIdentifiers: [itemIdentifier], options: options) + guard let fetchedAsset = fetchAssetResult.firstObject else { + self.logger.error("No Asset fetched") + Task.detached { + await MainActor.run { + self.exportState = .failure( + PickerError.missingAssetIdentifier + ) + } + } + return + } + + let exportOptions = PHVideoRequestOptions() + exportOptions.isNetworkAccessAllowed = true + exportOptions.deliveryMode = .highQualityFormat + self.assetRequestId = PHImageManager.default().requestExportSession( + forVideo: fetchedAsset, + options: exportOptions, + exportPreset: AVAssetExportPresetPassthrough, + resultHandler: {(exportSession, info) -> Void in + DispatchQueue.main.async { + guard let exportSession = exportSession else { + self.logger.error("!! No Export session") + self.exportState = .failure(UploadCreationModel.PickerError.assetExportSessionFailed) + return + } + self.exportToOutFile(session: exportSession, outFile: tempFile) + } + }) } - self.exportToOutFile(session: exportSession, outFile: tempFile) } - }) + } } private func exportToOutFile(session: AVAssetExportSession, outFile: URL) { @@ -180,32 +216,13 @@ class UploadCreationModel : ObservableObject { } - private func doRequestPhotosPermission() { - PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in - Task.detached { - await MainActor.run { - self.photosAuthStatus = status.asAppAuthState() - } - } - } - } - - private var assetRequestId: PHImageRequestID? = nil - private var prepareTask: Task? = nil - private var thumbnailGenerator: AVAssetImageGenerator? = nil - - private let logger = SwiftUploadSDKExample.logger - private let myServerBackend = FakeBackend(urlSession: URLSession(configuration: URLSessionConfiguration.default)) - - @Published - var photosAuthStatus: PhotosAuthState - @Published - var exportState: ExportState = .not_started - - init() { - let innerAuthStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) - self.photosAuthStatus = innerAuthStatus.asAppAuthState() - self.exportState = .not_started + private func doRequestPhotosPermission( + handler: @escaping (PHAuthorizationStatus) -> Void + ) { + PHPhotoLibrary.requestAuthorization( + for: .readWrite, + handler: handler + ) } } @@ -216,11 +233,16 @@ struct PreparedUpload { } enum ExportState { - case not_started, preparing, failure(UploadCreationModel.PickerError), ready(PreparedUpload) + case not_started + case preparing + case failure(UploadCreationModel.PickerError) + case ready(PreparedUpload) } enum PhotosAuthState { - case cant_auth(PHAuthorizationStatus), can_auth(PHAuthorizationStatus), authorized(PHAuthorizationStatus) + case cant_auth(PHAuthorizationStatus) + case can_auth(PHAuthorizationStatus) + case authorized(PHAuthorizationStatus) } extension PHAuthorizationStatus { diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Model/UploadListModel.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Model/UploadListModel.swift index b068dda8..0e1559c4 100644 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Model/UploadListModel.swift +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Model/UploadListModel.swift @@ -10,9 +10,15 @@ import AVFoundation import MuxUploadSDK class UploadListModel : ObservableObject { - - init() { - DirectUploadManager.shared.addDelegate( + + @Published var lastKnownUploads: [DirectUpload] + + init( + directUploadManager: DirectUploadManager = .shared + ) { + + self.lastKnownUploads = directUploadManager.allManagedDirectUploads() + directUploadManager.addDelegate( Delegate( handler: { uploads in @@ -39,8 +45,6 @@ class UploadListModel : ObservableObject { ) ) } - - @Published var lastKnownUploads: [DirectUpload] = Array() } fileprivate class Delegate: DirectUploadManagerDelegate { diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Screens/CreateUploadView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Screens/CreateUploadView.swift deleted file mode 100644 index e0b64491..00000000 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Screens/CreateUploadView.swift +++ /dev/null @@ -1,353 +0,0 @@ -// -// CreateUploadScreen.swift -// Test App -// -// Created by Emily Dixon on 5/9/23. -// - -import SwiftUI -import PhotosUI - -struct CreateUploadView: View { - - @StateObject var uploadCreationVM: UploadCreationModel = UploadCreationModel() - - var body: some View { - ZStack { // Outer window - Gray100.ignoresSafeArea(.container) - VStack(spacing: 0) { - MuxNavBar(leadingNavButton: .close, title: "Create a New Upload") - ScreenContentView(exportState: uploadCreationVM.exportState) - } - } - .environmentObject(uploadCreationVM) - } -} - -fileprivate struct ScreenContentView: View { - var body: some View { - ZStack { - WindowBackground - switch exportState { - case .not_started: EmptyView() - case .preparing: ProcessingView() - case .failure(let error): ErrorView(error: error) - case .ready(let upload): ThumbnailView(preparedMedia: upload) - } - - } - } - - let exportState: ExportState -} - -fileprivate struct ErrorView: View { - var body: some View { - VStack { - ZStack { - RoundedRectangle(cornerRadius: 4.0) - .strokeBorder(style: StrokeStyle(lineWidth: 1.0)) - .foregroundColor(Gray30) - .background(Gray90) - VStack { - Label( - "", - systemImage: "square.and.arrow.up.trianglebadge.exclamationmark" - ) - .foregroundColor(.red) - Spacer() - .frame(maxHeight: 12) - - Text(message) - .foregroundColor(White) - .multilineTextAlignment(.center) - .font(.system(size: 12)) - .padding(.leading) - .padding(.trailing) - } - } - .padding( - EdgeInsets( - top: 64, - leading: 20, - bottom: 0, - trailing: 20 - ) - ) - .frame(height: SwiftUploadSDKExample.THUMBNAIL_HEIGHT) - - Spacer() - } - } - - let error: Error? - - let message: String - - init(error: Error? = nil) { - self.error = error - self.message = "Couldn't prepare the video for upload. Please try another video." - } - - init(error: UploadCreationModel.PickerError) { - self.error = error - - if error == UploadCreationModel.PickerError.createUploadFailed { - self.message = "Couldn't create direct upload. Check your access token and network connectivity." - } else { - self.message = "Couldn't prepare the video for upload. Please try another video." - } - } -} - - -fileprivate struct ThumbnailView: View { - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack { - ZStack { - if let image = preparedMedia?.thumbnail { - GeometryReader { proxy in - RoundedRectangle(cornerRadius: 4.0) - .strokeBorder(style: StrokeStyle(lineWidth: 1.0)) - .foregroundColor(Gray30) - .background( - Image( - image, - scale: 1.0, - label: Text("") - ) - .resizable( ) - .scaledToFit() - .aspectRatio(contentMode: .fill) - .frame(maxWidth: proxy.size.width, maxHeight: proxy.size.height, alignment: .center) - .clipShape( - RoundedRectangle(cornerRadius: 4.0) - ) - ) - } - } else { - RoundedRectangle(cornerRadius: 4.0) - .strokeBorder(style: StrokeStyle(lineWidth: 1.0)) - .foregroundColor(Gray30) - .background(Gray30.clipShape(RoundedRectangle(cornerRadius: 4.0))) - // Processing can succeed without a thumbnail theoretically - Image(systemName: "video.badge.checkmark") - } - } - .padding( - EdgeInsets( - top: 64, - leading: 20, - bottom: 0, - trailing: 20 - ) - ) - .frame(height: SwiftUploadSDKExample.THUMBNAIL_HEIGHT) - Spacer() - StretchyDefaultButton("Upload") { - if let preparedMedia = preparedMedia { - uploadCreationVM.startUpload(preparedMedia: preparedMedia, forceRestart: true) - dismiss() - } - } - .padding() - } - } - - let preparedMedia: PreparedUpload? - @EnvironmentObject var uploadCreationVM: UploadCreationModel - - init(preparedMedia: PreparedUpload?) { - self.preparedMedia = preparedMedia - } -} - -fileprivate struct ProcessingView: View { - var body: some View { - VStack { - ZStack { - RoundedRectangle(cornerRadius: 4.0) - .strokeBorder(style: StrokeStyle(lineWidth: 1.0)) - .foregroundColor(Gray30) - .background(Gray30.clipShape(RoundedRectangle(cornerRadius: 4.0))) - ProgressView() - .foregroundColor(Gray30) - } - .padding( - EdgeInsets( - top: 64, - leading: 20, - bottom: 0, - trailing: 20 - ) - ) - .frame(height: SwiftUploadSDKExample.THUMBNAIL_HEIGHT) - Spacer() - } - } -} - -fileprivate struct EmptyView: View { - var body: some View { - VStack { - UploadCTA() - .padding( - EdgeInsets( - top: 64, - leading: 20, - bottom: 0, - trailing: 20 - ) - ) - Spacer() - } - } -} - -fileprivate struct UploadCTA: View { - @EnvironmentObject var uploadCreationVM: UploadCreationModel - @State var inPickFlow = false // True when picking photos or resolving the related permission prompt, or when first launching the screen - - private var pickerConfig: PHPickerConfiguration = { - let mediaFilter = PHPickerFilter.any(of: [.videos]) - var photoPickerConfig = PHPickerConfiguration(photoLibrary: .shared()) - photoPickerConfig.filter = mediaFilter - photoPickerConfig.preferredAssetRepresentationMode = .current - photoPickerConfig.selectionLimit = 1 - if #available(iOS 15.0, *) { - photoPickerConfig.selection = .default - } - return photoPickerConfig - }() - - var body: some View { - Button { [self] in - switch uploadCreationVM.photosAuthStatus { - case .can_auth(_): do { - inPickFlow = true - uploadCreationVM.requestPhotosAccess() - } - case .authorized(_): do { - inPickFlow = true - } - case .cant_auth(_): do { - NSLog("!! This app cannot ask for or gain Photos access permissions for some reason. We don't expect to see this on a real device unless 'NSPhotoLibraryAddUsageDescription' is gone from the app plist") - } - } - } label : { - BigUploadCTALabel() - } - .contentShape(Rectangle()) - .disabled(shouldDisableButton()) - .sheet( - isPresented: Binding( - get: { self.shouldShowPhotoPicker() }, - set: { value, _ in inPickFlow = value && isAuthorizedForPhotos() } - ), - content: { ImagePicker( - pickerConfiguration: self.pickerConfig, - delegate: { (images: [PHPickerResult]) in - // Only 0 or 1 images can be selected - if let firstVideo = images.first { - inPickFlow = false - uploadCreationVM.tryToPrepare(from: firstVideo) - } - } - )} - ) - .task { - inPickFlow = true - if case .can_auth(_) = uploadCreationVM.photosAuthStatus { - uploadCreationVM.requestPhotosAccess() - } - } - } - - private func shouldShowPhotoPicker() -> Bool { - if isAuthorizedForPhotos() { - return inPickFlow - } else { - return false - } - } - - private func shouldDisableButton() -> Bool { - if isAuthorizedForPhotos() { - return false - } else { - return inPickFlow - } - } - - private func isAuthorizedForPhotos() -> Bool { - switch uploadCreationVM.photosAuthStatus { - case .authorized(_): return true - default: return false - } - } - - let actionOnMediaAvailable: (PHAsset, URL) -> Void - - init(_ actionOnMediaAvailable: @escaping (PHAsset, URL) -> Void = {_,_ in }) { - self.actionOnMediaAvailable = actionOnMediaAvailable - } -} - -struct ContentContainer_Previews: PreviewProvider { - static var previews: some View { - let exportState = ExportState.ready( - PreparedUpload(thumbnail: nil, localVideoFile: URL(string: "file:///")!, remoteURL: URL(string: "file:///")!) - ) - ScreenContentView(exportState: exportState) - .environmentObject(UploadCreationModel()) - } -} - -struct EntireScreen_Previews: PreviewProvider { - static var previews: some View { - CreateUploadView() - .environmentObject(UploadCreationModel()) - } -} - -struct Thumbnail_Previews: PreviewProvider { - static var previews: some View { - ZStack { - WindowBackground.ignoresSafeArea() - ThumbnailView(preparedMedia: PreparedUpload(thumbnail: nil, localVideoFile: URL(string: "file:///")!, remoteURL: URL(string: "file:///")!)) - } - .environmentObject(UploadCreationModel()) - } -} - -struct EmptyView_Previews: PreviewProvider { - static var previews: some View { - ZStack { - WindowBackground.ignoresSafeArea() - EmptyView() - } - .environmentObject(UploadCreationModel()) - } -} - -struct ErrorView_Previews: PreviewProvider { - static var previews: some View { - ZStack { - WindowBackground.ignoresSafeArea() - ErrorView() - } - .environmentObject(UploadCreationModel()) - } -} - -struct ProgressView_Previews: PreviewProvider { - static var previews: some View { - ZStack { - WindowBackground.ignoresSafeArea() - ProcessingView() - } - .environmentObject(UploadCreationModel()) - } -} diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/SwiftUploadSDKExample.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/SwiftUploadSDKExample.swift index 51375a33..ab922d19 100644 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/SwiftUploadSDKExample.swift +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/SwiftUploadSDKExample.swift @@ -11,19 +11,20 @@ import OSLog @main struct SwiftUploadSDKExample: App { - static var logger = Logger(subsystem: "mux", category: "default") - static let THUMBNAIL_HEIGHT = 228.0 - - @StateObject private var uploadListVM = UploadListModel() + static var logger = Logger( + subsystem: "UploadExample", + category: "diagnostics" + ) + static let thumbnailHeight = 228.0 + @StateObject var uploadListModel = UploadListModel() + @StateObject var uploadCreationModel = UploadCreationModel() + var body: some Scene { WindowGroup { ContentView() - .environmentObject(uploadListVM) + .environmentObject(uploadListModel) + .environmentObject(uploadCreationModel) } } - - public init() { - //MuxUploadSDK.enableDefaultLogging() // note: Kind of noisy on the simulator - } } diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ContentView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ContentView.swift new file mode 100644 index 00000000..5f16f4db --- /dev/null +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ContentView.swift @@ -0,0 +1,48 @@ +// +// ContentView.swift +// Test App +// +// Created by Emily Dixon on 2/14/23. +// +import SwiftUI +import PhotosUI +import MuxUploadSDK + +struct ContentView: View { + @EnvironmentObject var uploadListModel: UploadListModel + @EnvironmentObject var uploadCreationModel: UploadCreationModel + + var body: some View { + NavigationView { + VStack(spacing: 0) { + MuxNavBar() + UploadListView() + Spacer() + } + .background { + WindowBackground + } + NavigationLink { + CreateUploadView() + .navigationBarHidden(true) + } label : { + if !uploadListModel.lastKnownUploads.isEmpty { + ZStack { + Image("Mux-y Add") + .padding() + .background(Green50.clipShape(Circle())) + } + .padding(24.0) + } + } + } + .background { + WindowBackground + .ignoresSafeArea() + } + .navigationViewStyle(.stack) + .preferredColorScheme(.dark) + .environmentObject(uploadListModel) + .environmentObject(uploadCreationModel) + } +} diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/CreateUploadView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/CreateUploadView.swift new file mode 100644 index 00000000..f7f67d6e --- /dev/null +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/CreateUploadView.swift @@ -0,0 +1,38 @@ +// +// CreateUploadScreen.swift +// Test App +// +// Created by Emily Dixon on 5/9/23. +// + +import SwiftUI +import PhotosUI + +struct CreateUploadView: View { + + @EnvironmentObject var uploadCreationModel: UploadCreationModel + + var body: some View { + ZStack { // Outer window + Gray100.ignoresSafeArea(.container) + VStack(spacing: 0) { + MuxNavBar( + leadingNavButton: .close, + title: "Create a New Upload" + ) + ZStack { + WindowBackground + switch uploadCreationModel.exportState { + case .not_started: SelectVideoView() + case .preparing: ProcessingView() + case .failure(let error): ErrorView(error: error) + case .ready(let upload): ThumbnailView(preparedMedia: upload) + } + + } + } + } + .environmentObject(uploadCreationModel) + } +} + diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ErrorView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ErrorView.swift new file mode 100644 index 00000000..1aad7fb8 --- /dev/null +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ErrorView.swift @@ -0,0 +1,75 @@ +// +// ErrorView.swift +// SwiftUploadSDKExample +// + +import SwiftUI + +struct ErrorView: View { + var body: some View { + VStack { + ZStack { + RoundedRectangle(cornerRadius: 4.0) + .strokeBorder(style: StrokeStyle(lineWidth: 1.0)) + .foregroundColor(Gray30) + .background(Gray90) + VStack { + Label( + "", + systemImage: "square.and.arrow.up.trianglebadge.exclamationmark" + ) + .foregroundColor(.red) + Spacer() + .frame(maxHeight: 12) + + Text(message) + .foregroundColor(White) + .multilineTextAlignment(.center) + .font(.system(size: 12)) + .padding(.leading) + .padding(.trailing) + } + } + .padding( + EdgeInsets( + top: 64, + leading: 20, + bottom: 0, + trailing: 20 + ) + ) + .frame(height: SwiftUploadSDKExample.thumbnailHeight) + + Spacer() + } + } + + let error: Error? + + let message: String + + init(error: Error? = nil) { + self.error = error + self.message = "Couldn't prepare the video for upload. Please try another video." + } + + init(error: UploadCreationModel.PickerError) { + self.error = error + + if error == UploadCreationModel.PickerError.createUploadFailed { + self.message = "Couldn't create direct upload. Check your access token and network connectivity." + } else { + self.message = "Couldn't prepare the video for upload. Please try another video." + } + } +} + +struct ErrorView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + WindowBackground.ignoresSafeArea() + ErrorView() + } + .environmentObject(UploadCreationModel()) + } +} diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ProcessingView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ProcessingView.swift new file mode 100644 index 00000000..a60e1147 --- /dev/null +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ProcessingView.swift @@ -0,0 +1,42 @@ +// +// ProcessingView.swift +// SwiftUploadSDKExample +// + +import SwiftUI + +struct ProcessingView: View { + var body: some View { + VStack { + ZStack { + RoundedRectangle(cornerRadius: 4.0) + .strokeBorder(style: StrokeStyle(lineWidth: 1.0)) + .foregroundColor(Gray30) + .background(Gray30.clipShape(RoundedRectangle(cornerRadius: 4.0))) + ProgressView() + .foregroundColor(Gray30) + } + .padding( + EdgeInsets( + top: 64, + leading: 20, + bottom: 0, + trailing: 20 + ) + ) + .frame(height: SwiftUploadSDKExample.thumbnailHeight) + Spacer() + } + } +} + +struct ProcessingView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + WindowBackground.ignoresSafeArea() + ProcessingView() + } + .environmentObject(UploadCreationModel()) + } +} + diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/SelectVideoView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/SelectVideoView.swift new file mode 100644 index 00000000..aa4f6dbf --- /dev/null +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/SelectVideoView.swift @@ -0,0 +1,48 @@ +// +// SelectVideoView.swift +// SwiftUploadSDKExample +// + +import PhotosUI +import SwiftUI + +struct SelectVideoView: View { + @EnvironmentObject var uploadCreationModel: UploadCreationModel + + @State var pickedItem: [PhotosPickerItem] = [] + + var body: some View { + VStack { + PhotosPicker( + selection: $uploadCreationModel.pickedItem, + maxSelectionCount: 1, + selectionBehavior: .default, + matching: .videos, + preferredItemEncoding: .current, + photoLibrary: .shared(), + label: { + UploadCallToActionLabel() + } + ) + .padding( + EdgeInsets( + top: 64, + leading: 20, + bottom: 0, + trailing: 20 + ) + ) + Spacer() + } + } +} + +struct SelectVideoView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + WindowBackground.ignoresSafeArea() + SelectVideoView() + } + .environmentObject(UploadCreationModel()) + } +} diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ThumbnailView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ThumbnailView.swift new file mode 100644 index 00000000..88a835c7 --- /dev/null +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/ThumbnailView.swift @@ -0,0 +1,81 @@ +// +// ThumbnailView.swift +// SwiftUploadSDKExample +// + +import SwiftUI + +struct ThumbnailView: View { + let preparedMedia: PreparedUpload? + @EnvironmentObject var uploadCreationModel: UploadCreationModel + @Environment(\.dismiss) private var dismiss + + init(preparedMedia: PreparedUpload?) { + self.preparedMedia = preparedMedia + } + + var body: some View { + VStack { + ZStack { + if let image = preparedMedia?.thumbnail { + GeometryReader { proxy in + RoundedRectangle(cornerRadius: 4.0) + .strokeBorder(style: StrokeStyle(lineWidth: 1.0)) + .foregroundColor(Gray30) + .background( + Image( + image, + scale: 1.0, + label: Text("") + ) + .resizable( ) + .scaledToFit() + .aspectRatio(contentMode: .fill) + .frame(maxWidth: proxy.size.width, maxHeight: proxy.size.height, alignment: .center) + .clipShape( + RoundedRectangle(cornerRadius: 4.0) + ) + ) + } + } else { + RoundedRectangle(cornerRadius: 4.0) + .strokeBorder(style: StrokeStyle(lineWidth: 1.0)) + .foregroundColor(Gray30) + .background(Gray30.clipShape(RoundedRectangle(cornerRadius: 4.0))) + // Processing can succeed without a thumbnail theoretically + Image(systemName: "video.badge.checkmark") + } + } + .padding( + EdgeInsets( + top: 64, + leading: 20, + bottom: 0, + trailing: 20 + ) + ) + .frame(height: SwiftUploadSDKExample.thumbnailHeight) + Spacer() + StretchyDefaultButton("Upload") { + if let preparedMedia = preparedMedia { + uploadCreationModel.startUpload( + preparedMedia: preparedMedia, + forceRestart: true + ) + dismiss() + } + } + .padding() + } + } +} + +struct ThumbnailView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + WindowBackground.ignoresSafeArea() + ThumbnailView(preparedMedia: PreparedUpload(thumbnail: nil, localVideoFile: URL(string: "file:///")!, remoteURL: URL(string: "file:///")!)) + } + .environmentObject(UploadCreationModel()) + } +} diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Widgets/UploadCTA.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadCallToActionLabel.swift similarity index 82% rename from Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Widgets/UploadCTA.swift rename to Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadCallToActionLabel.swift index 6c7b1076..e319a3bb 100644 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Widgets/UploadCTA.swift +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadCallToActionLabel.swift @@ -8,7 +8,7 @@ import SwiftUI import PhotosUI -struct BigUploadCTALabel: View { +struct UploadCallToActionLabel: View { var body: some View { ZStack { RoundedRectangle(cornerRadius: 4.0) @@ -23,17 +23,15 @@ struct BigUploadCTALabel: View { .foregroundColor(White) } } - .frame(height: SwiftUploadSDKExample.THUMBNAIL_HEIGHT) + .frame(height: SwiftUploadSDKExample.thumbnailHeight) } } - - -struct BigUploadCTA_Preview: PreviewProvider { +struct UploadCallToActionLabel_Preview: PreviewProvider { static var previews: some View { ZStack { WindowBackground.ignoresSafeArea() - BigUploadCTALabel() + UploadCallToActionLabel() .padding(EdgeInsets(top: 64, leading: 20, bottom: 0, trailing: 20)) } .environmentObject(UploadCreationModel()) diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadListPlaceholderView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadListPlaceholderView.swift new file mode 100644 index 00000000..54c025f8 --- /dev/null +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadListPlaceholderView.swift @@ -0,0 +1,30 @@ +// +// UploadListPlaceholderView.swift +// SwiftUploadSDKExample +// + +import SwiftUI + +struct UploadListPlaceholderView: View { + var body: some View { + NavigationLink { + CreateUploadView() + .navigationBarHidden(true) + } label: { + ZStack(alignment: .top) { + UploadCallToActionLabel() + .padding(EdgeInsets(top: 64, leading: 20, bottom: 0, trailing: 20)) + } + } + } +} + +struct UploadListPlaceholderView_Previews: PreviewProvider { + static var previews: some View { + ZStack(alignment: .top) { + WindowBackground + UploadListPlaceholderView() + + } + } +} diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Screens/UploadListView.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadListView.swift similarity index 75% rename from Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Screens/UploadListView.swift rename to Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadListView.swift index cb8a2d12..a437a0c9 100644 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Screens/UploadListView.swift +++ b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Views/UploadListView.swift @@ -9,37 +9,25 @@ import SwiftUI import MuxUploadSDK import AVFoundation -struct UploadListScreen: View { - @EnvironmentObject var uploadListVM: UploadListModel - - var body: some View { - ZStack(alignment: .top) { - WindowBackground - VStack(spacing: 0) { - MuxNavBar() - ListContainerView() - } - } - } -} - extension DirectUpload { var objectIdentifier: ObjectIdentifier { ObjectIdentifier(self) } } -fileprivate struct ListContainerView: View { - - @EnvironmentObject var viewModel: UploadListModel - +struct UploadListView: View { + @EnvironmentObject var uploadListModel: UploadListModel + var body: some View { - if viewModel.lastKnownUploads.isEmpty { - EmptyList() + if uploadListModel.lastKnownUploads.isEmpty { + UploadListPlaceholderView() } else { ScrollView { LazyVStack { - ForEach(viewModel.lastKnownUploads, id: \.objectIdentifier) { upload in + ForEach( + uploadListModel.lastKnownUploads, + id: \.objectIdentifier + ) { upload in ListItem(upload: upload) } } @@ -51,7 +39,6 @@ fileprivate struct ListContainerView: View { fileprivate struct ListItem: View { @StateObject var thumbnailModel: ThumbnailModel - let upload: DirectUpload var body: some View { ZStack(alignment: .bottom) { @@ -81,7 +68,7 @@ fileprivate struct ListItem: View { .foregroundColor(Gray30) .background(Gray90.clipShape(RoundedRectangle(cornerRadius: 4.0))) } - if upload.inProgress { + if thumbnailModel.upload.inProgress { HStack() { VStack (alignment: .leading, spacing: 0) { Text("Uploading...") @@ -124,7 +111,7 @@ fileprivate struct ListItem: View { trailing: 20 ) ) - .frame(height: SwiftUploadSDKExample.THUMBNAIL_HEIGHT) + .frame(height: SwiftUploadSDKExample.thumbnailHeight) .onAppear { thumbnailModel.startExtractingThumbnail() } @@ -160,61 +147,35 @@ fileprivate struct ListItem: View { } init(upload: DirectUpload) { - self.upload = upload _thumbnailModel = StateObject( - wrappedValue: { - ThumbnailModel(asset: AVAsset(url: upload.videoFile!), upload: upload) - }() + wrappedValue: ThumbnailModel( + upload: upload + ) ) } } -fileprivate struct EmptyList: View { - var body: some View { - NavigationLink { - CreateUploadView() - .navigationBarHidden(true) - } label: { - ZStack(alignment: .top) { - BigUploadCTALabel() - .padding(EdgeInsets(top: 64, leading: 20, bottom: 0, trailing: 20)) - } - } - } -} - -struct UploadListScreen_Previews: PreviewProvider { - static var previews: some View { - UploadListScreen() - .environmentObject(UploadListModel()) - } -} - -struct ListContent_Previews: PreviewProvider { +struct UploadListView_Previews: PreviewProvider { static var previews: some View { ZStack(alignment: .top) { WindowBackground - ListContainerView() + UploadListView() } .environmentObject(UploadListModel()) } } -struct EmptyList_Previews: PreviewProvider { - static var previews: some View { - ZStack(alignment: .top) { - WindowBackground - EmptyList() - - } - } -} + struct UploadListItem_Previews: PreviewProvider { static var previews: some View { ZStack { WindowBackground - let upload = DirectUpload(uploadURL: URL(string: "file:///")!, videoFileURL: URL(string: "file:///")!) + let upload = DirectUpload( + uploadURL: URL(string: "file:///")!, + inputAsset: AVAsset(), + options: .default + ) ListItem(upload: upload) } } diff --git a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Widgets/ImagePicker.swift b/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Widgets/ImagePicker.swift deleted file mode 100644 index 62af4588..00000000 --- a/Example/SwiftUploadSDKExample/SwiftUploadSDKExample/Widgets/ImagePicker.swift +++ /dev/null @@ -1,36 +0,0 @@ -import PhotosUI -import SwiftUI - -struct ImagePicker: UIViewControllerRepresentable { - let pickerConfiguration: PHPickerConfiguration - let delegate: PickerResultHandler - - func makeUIViewController(context: Context) -> PHPickerViewController { - let picker = PHPickerViewController(configuration: pickerConfiguration) - picker.delegate = context.coordinator - return picker - } - - func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { - - } - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - typealias PickerResultHandler = ([PHPickerResult]) -> Void - - class Coordinator: NSObject, PHPickerViewControllerDelegate { - let parent: ImagePicker - - init(_ parent: ImagePicker) { - self.parent = parent - } - - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - picker.dismiss(animated: true) - parent.delegate(results) - } - } -} diff --git a/Mux-Upload-SDK.podspec b/Mux-Upload-SDK.podspec index 112c4e45..c36a7949 100644 --- a/Mux-Upload-SDK.podspec +++ b/Mux-Upload-SDK.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = 'Mux-Upload-SDK' s.module_name = 'MuxUploadSDK' - s.version = '0.7.0' + s.version = '1.0.0' s.summary = 'Upload video to Mux.' s.description = 'A library for uploading video to Mux. Similar to UpChunk, but for iOS.' diff --git a/Sources/MuxUploadSDK/InputInspection/UploadInputFormatInspectionResult.swift b/Sources/MuxUploadSDK/InputInspection/UploadInputFormatInspectionResult.swift index e8c33395..ad93e995 100644 --- a/Sources/MuxUploadSDK/InputInspection/UploadInputFormatInspectionResult.swift +++ b/Sources/MuxUploadSDK/InputInspection/UploadInputFormatInspectionResult.swift @@ -5,8 +5,7 @@ import AVFoundation import Foundation -enum UploadInputFormatInspectionResult { - +struct UploadInputFormatInspectionResult { enum NonstandardInputReason { case videoCodec case audioCodec @@ -21,40 +20,42 @@ enum UploadInputFormatInspectionResult { case unsupportedPixelFormat } - case inspectionFailure(duration: CMTime) - case standard(duration: CMTime) - case nonstandard( - reasons: [NonstandardInputReason], - duration: CMTime - ) + var nonStandardInputReasons: [NonstandardInputReason] = [] - var isStandard: Bool { - if case Self.standard = self { - return true - } else { - return false - } + var isStandardInput: Bool { + nonStandardInputReasons.isEmpty } - var sourceInputDuration: CMTime { - switch self { - case .inspectionFailure(duration: let duration): - return duration - case .standard(duration: let duration): - return duration - case .nonstandard(_, duration: let duration): - return duration - } - } + struct RescalingDetails { + var maximumDesiredResolutionPreset: DirectUploadOptions.InputStandardization.MaximumResolution = .default + + var recordedResolution: CMVideoDimensions = CMVideoDimensions(width: 0, height: 0) - var nonstandardInputReasons: [NonstandardInputReason]? { - if case Self.nonstandard(let nonstandardInputReasons, _) = self { - return nonstandardInputReasons - } else { - return nil + var needsRescaling: Bool { + switch maximumDesiredResolutionPreset { + case .default, .preset1920x1080: + if max(recordedResolution.width, recordedResolution.height) > 1920 { + return true + } else { + return false + } + case .preset1280x720: + if max(recordedResolution.width, recordedResolution.height) > 1280 { + return true + } else { + return false + } + case .preset3840x2160: + if max(recordedResolution.width, recordedResolution.height) > 3840 { + return true + } else { + return false + } + } } } + var rescalingDetails: RescalingDetails = RescalingDetails() } extension UploadInputFormatInspectionResult.NonstandardInputReason: CustomStringConvertible { diff --git a/Sources/MuxUploadSDK/InputInspection/UploadInputInspector.swift b/Sources/MuxUploadSDK/InputInspection/UploadInputInspector.swift index c1f388d8..eb18d072 100644 --- a/Sources/MuxUploadSDK/InputInspection/UploadInputInspector.swift +++ b/Sources/MuxUploadSDK/InputInspection/UploadInputInspector.swift @@ -6,13 +6,22 @@ import AVFoundation import CoreMedia import Foundation +typealias UploadInputInspectionCompletionHandler = (UploadInputFormatInspectionResult?, CMTime, Error?) -> () + protocol UploadInputInspector { func performInspection( sourceInput: AVAsset, - completionHandler: @escaping (UploadInputFormatInspectionResult) -> () + maximumResolution: DirectUploadOptions.InputStandardization.MaximumResolution, + completionHandler: @escaping UploadInputInspectionCompletionHandler ) } +struct UploadInputInspectionError: Error { + + static let inspectionFailure = UploadInputInspectionError() + +} + class AVFoundationUploadInputInspector: UploadInputInspector { static let shared = AVFoundationUploadInputInspector() @@ -24,7 +33,8 @@ class AVFoundationUploadInputInspector: UploadInputInspector { // methods. func performInspection( sourceInput: AVAsset, - completionHandler: @escaping (UploadInputFormatInspectionResult) -> () + maximumResolution: DirectUploadOptions.InputStandardization.MaximumResolution, + completionHandler: @escaping UploadInputInspectionCompletionHandler ) { // TODO: Eventually load audio tracks too if #available(iOS 15, *) { @@ -33,7 +43,9 @@ class AVFoundationUploadInputInspector: UploadInputInspector { ) { tracks, error in if error != nil { completionHandler( - .inspectionFailure(duration: CMTime.zero) + nil, + CMTime.zero, + UploadInputInspectionError.inspectionFailure ) return } @@ -42,6 +54,7 @@ class AVFoundationUploadInputInspector: UploadInputInspector { self.inspect( sourceInput: sourceInput, tracks: tracks, + maximumResolution: maximumResolution, completionHandler: completionHandler ) } @@ -60,6 +73,7 @@ class AVFoundationUploadInputInspector: UploadInputInspector { self.inspect( sourceInput: sourceInput, tracks: tracks, + maximumResolution: maximumResolution, completionHandler: completionHandler ) } @@ -69,14 +83,17 @@ class AVFoundationUploadInputInspector: UploadInputInspector { func inspect( sourceInput: AVAsset, tracks: [AVAssetTrack], - completionHandler: @escaping (UploadInputFormatInspectionResult) -> () + maximumResolution: DirectUploadOptions.InputStandardization.MaximumResolution, + completionHandler: @escaping UploadInputInspectionCompletionHandler ) { switch tracks.count { case 0: // Nothing to inspect, therefore nothing to standardize // declare as already standard completionHandler( - .standard(duration: CMTime.zero) + UploadInputFormatInspectionResult(), + CMTime.zero, + nil ) case 1: @@ -90,32 +107,35 @@ class AVFoundationUploadInputInspector: UploadInputInspector { track.loadValuesAsynchronously( forKeys: [ "formatDescriptions", - "nominalFrameRate" + "nominalFrameRate", + "estimatedDataRate" ] ) { + var nonStandardReasons: [UploadInputFormatInspectionResult.NonstandardInputReason] = [] + guard let formatDescriptions = track.formatDescriptions as? [CMFormatDescription] else { completionHandler( - .inspectionFailure( - duration: sourceInputDuration - ) + nil, + sourceInputDuration, + UploadInputInspectionError.inspectionFailure ) return } guard let formatDescription = formatDescriptions.first else { completionHandler( - .inspectionFailure(duration: sourceInputDuration) + nil, + sourceInputDuration, + UploadInputInspectionError.inspectionFailure ) return } - var nonStandardReasons: [UploadInputFormatInspectionResult.NonstandardInputReason] = [] - let videoDimensions = CMVideoFormatDescriptionGetDimensions( formatDescription ) - if max(videoDimensions.width, videoDimensions.height) > 1920 { + if max(videoDimensions.width, videoDimensions.height) > 3840 { nonStandardReasons.append(.videoResolution) } @@ -128,18 +148,40 @@ class AVFoundationUploadInputInspector: UploadInputInspector { } let frameRate = track.nominalFrameRate - if frameRate > 120.0 { - nonStandardReasons.append(.videoFrameRate) + + if max(videoDimensions.width, videoDimensions.height) > 1920 { + if frameRate > 60.0 { + nonStandardReasons.append(.videoFrameRate) + } + } else { + if frameRate > 120.0 { + nonStandardReasons.append(.videoFrameRate) + } } - if nonStandardReasons.isEmpty { - completionHandler( - .standard(duration: sourceInputDuration) - ) + let estimatedBitrate = track.estimatedDataRate + + if max(videoDimensions.width, videoDimensions.height) > 1920 { + if estimatedBitrate > 40_000_000 { + nonStandardReasons.append(.videoBitrate) + } } else { - completionHandler(.nonstandard(reasons: nonStandardReasons, duration: sourceInputDuration)) + if estimatedBitrate > 16_000_000 { + nonStandardReasons.append(.videoBitrate) + } } + completionHandler( + UploadInputFormatInspectionResult( + nonStandardInputReasons: nonStandardReasons, + rescalingDetails: UploadInputFormatInspectionResult.RescalingDetails( + maximumDesiredResolutionPreset: maximumResolution, + recordedResolution: videoDimensions + ) + ), + sourceInputDuration, + nil + ) } } } @@ -147,7 +189,9 @@ class AVFoundationUploadInputInspector: UploadInputInspector { // Inspection fails for multi-video track inputs // for the time being completionHandler( - .inspectionFailure(duration: CMTime.zero) + nil, + CMTime.zero, + UploadInputInspectionError.inspectionFailure ) } } diff --git a/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizationWorker.swift b/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizationWorker.swift index d9ecbc07..2fa17b26 100644 --- a/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizationWorker.swift +++ b/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizationWorker.swift @@ -42,19 +42,25 @@ class UploadInputStandardizationWorker { var standardizedInput: AVAsset? func standardize( - sourceAsset: AVAsset, - maximumResolution: DirectUploadOptions.InputStandardization.MaximumResolution, + sourceAsset: AVURLAsset, + rescalingDetails: UploadInputFormatInspectionResult.RescalingDetails, outputURL: URL, - completion: @escaping (AVAsset, AVAsset?, Error?) -> () + completion: @escaping (AVURLAsset, AVAsset?, Error?) -> () ) { let availableExportPresets = AVAssetExportSession.allExportPresets() let exportPreset: String - if maximumResolution == .preset1280x720 { + + switch rescalingDetails.maximumDesiredResolutionPreset { + case .default: + exportPreset = AVAssetExportPreset1920x1080 + case .preset1280x720: exportPreset = AVAssetExportPreset1280x720 - } else { + case .preset1920x1080: exportPreset = AVAssetExportPreset1920x1080 + case .preset3840x2160: + exportPreset = AVAssetExportPreset3840x2160 } guard availableExportPresets.contains(where: { diff --git a/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizer.swift b/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizer.swift index c2505d5e..56e56cc5 100644 --- a/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizer.swift +++ b/Sources/MuxUploadSDK/InputStandardization/UploadInputStandardizer.swift @@ -10,16 +10,16 @@ class UploadInputStandardizer { func standardize( id: String, - sourceAsset: AVAsset, - maximumResolution: DirectUploadOptions.InputStandardization.MaximumResolution, + sourceAsset: AVURLAsset, + rescalingDetails: UploadInputFormatInspectionResult.RescalingDetails, outputURL: URL, - completion: @escaping (AVAsset, AVAsset?, Error?) -> () + completion: @escaping (AVURLAsset, AVAsset?, Error?) -> () ) { let worker = UploadInputStandardizationWorker() worker.standardize( sourceAsset: sourceAsset, - maximumResolution: maximumResolution, + rescalingDetails: rescalingDetails, outputURL: outputURL, completion: completion ) diff --git a/Sources/MuxUploadSDK/InternalUtilities/UploadInput.swift b/Sources/MuxUploadSDK/InternalUtilities/UploadInput.swift index 97100c1a..2b3e2092 100644 --- a/Sources/MuxUploadSDK/InternalUtilities/UploadInput.swift +++ b/Sources/MuxUploadSDK/InternalUtilities/UploadInput.swift @@ -10,26 +10,26 @@ import Foundation struct UploadInput { internal enum Status { - case ready(AVAsset, UploadInfo) - case started(AVAsset, UploadInfo) - case underInspection(AVAsset, UploadInfo) - case standardizing(AVAsset, UploadInfo) + case ready(AVURLAsset, UploadInfo) + case started(AVURLAsset, UploadInfo) + case underInspection(AVURLAsset, UploadInfo) + case standardizing(AVURLAsset, UploadInfo) case standardizationSucceeded( - source: AVAsset, - standardized: AVAsset?, + source: AVURLAsset, + standardized: AVURLAsset?, uploadInfo: UploadInfo ) - case standardizationFailed(AVAsset, UploadInfo) - case awaitingUploadConfirmation(UploadInfo) - case uploadInProgress(UploadInfo, DirectUpload.TransportStatus) - case uploadPaused(UploadInfo, DirectUpload.TransportStatus) - case uploadSucceeded(UploadInfo, DirectUpload.SuccessDetails) - case uploadFailed(UploadInfo, DirectUploadError) + case standardizationFailed(AVURLAsset, UploadInfo) + case awaitingUploadConfirmation(AVURLAsset,UploadInfo) + case uploadInProgress(AVURLAsset, UploadInfo, DirectUpload.TransportStatus) + case uploadPaused(AVURLAsset, UploadInfo, DirectUpload.TransportStatus) + case uploadSucceeded(AVURLAsset, UploadInfo, DirectUpload.SuccessDetails) + case uploadFailed(AVURLAsset, UploadInfo, DirectUploadError) } var status: Status - var sourceAsset: AVAsset { + var sourceAsset: AVURLAsset { switch status { case .ready(let sourceAsset, _): return sourceAsset @@ -43,16 +43,16 @@ struct UploadInput { return sourceAsset case .standardizationFailed(let sourceAsset, _): return sourceAsset - case .awaitingUploadConfirmation(let uploadInfo): - return uploadInfo.sourceAsset() - case .uploadInProgress(let uploadInfo, _): - return uploadInfo.sourceAsset() - case .uploadSucceeded(let uploadInfo, _): - return uploadInfo.sourceAsset() - case .uploadFailed(let uploadInfo, _): - return uploadInfo.sourceAsset() - case .uploadPaused(let uploadInfo, _): - return uploadInfo.sourceAsset() + case .awaitingUploadConfirmation(let sourceAsset, _): + return sourceAsset + case .uploadInProgress(let sourceAsset, _, _): + return sourceAsset + case .uploadSucceeded(let sourceAsset, _, _): + return sourceAsset + case .uploadFailed(let sourceAsset, _, _): + return sourceAsset + case .uploadPaused(let sourceAsset, _, _): + return sourceAsset } } @@ -70,15 +70,15 @@ struct UploadInput { return uploadInfo case .standardizationFailed(_, let uploadInfo): return uploadInfo - case .awaitingUploadConfirmation(let uploadInfo): + case .awaitingUploadConfirmation(_, let uploadInfo): return uploadInfo - case .uploadInProgress(let uploadInfo, _): + case .uploadInProgress(_, let uploadInfo, _): return uploadInfo - case .uploadPaused(let uploadInfo, _): + case .uploadPaused(_, let uploadInfo, _): return uploadInfo - case .uploadSucceeded(let uploadInfo, _): + case .uploadSucceeded(_, let uploadInfo, _): return uploadInfo - case .uploadFailed(let uploadInfo, _): + case .uploadFailed(_, let uploadInfo, _): return uploadInfo } } @@ -99,11 +99,11 @@ struct UploadInput { return nil case .awaitingUploadConfirmation: return nil - case .uploadInProgress(_, let transportStatus): + case .uploadInProgress(_, _, let transportStatus): return transportStatus - case .uploadPaused(_, let transportStatus): + case .uploadPaused(_, _, let transportStatus): return transportStatus - case .uploadSucceeded(_, let successDetails): + case .uploadSucceeded(_, _, let successDetails): return successDetails.finalState case .uploadFailed: return nil @@ -125,7 +125,7 @@ extension UploadInput { startingTransportStatus: DirectUpload.TransportStatus ) { if case UploadInput.Status.underInspection = status { - status = .uploadInProgress(uploadInfo, startingTransportStatus) + status = .uploadInProgress(sourceAsset, uploadInfo, startingTransportStatus) } else { return } @@ -134,15 +134,15 @@ extension UploadInput { mutating func processUploadSuccess( transportStatus: DirectUpload.TransportStatus ) { - if case UploadInput.Status.uploadInProgress(let info, _) = status { - status = .uploadSucceeded(info, DirectUpload.SuccessDetails(finalState: transportStatus)) + if case UploadInput.Status.uploadInProgress(let asset, let info, _) = status { + status = .uploadSucceeded(asset, info, DirectUpload.SuccessDetails(finalState: transportStatus)) } else { return } } mutating func processUploadFailure(error: DirectUploadError) { - status = .uploadFailed(uploadInfo, error) + status = .uploadFailed(sourceAsset, uploadInfo, error) } } @@ -151,7 +151,7 @@ extension UploadInput.Status: Equatable { } extension UploadInput { init( - asset: AVAsset, + asset: AVURLAsset, info: UploadInfo ) { self.status = .ready(asset, info) diff --git a/Sources/MuxUploadSDK/PublicAPI/AVFoundation+DirectUpload/DirectUpload+AVFoundation.swift b/Sources/MuxUploadSDK/PublicAPI/AVFoundation+DirectUpload/DirectUpload+AVFoundation.swift index 12ff1b41..9b6115f8 100644 --- a/Sources/MuxUploadSDK/PublicAPI/AVFoundation+DirectUpload/DirectUpload+AVFoundation.swift +++ b/Sources/MuxUploadSDK/PublicAPI/AVFoundation+DirectUpload/DirectUpload+AVFoundation.swift @@ -22,9 +22,16 @@ extension DirectUpload { inputAsset: AVAsset, options: DirectUploadOptions ) { + guard let urlAsset = inputAsset as? AVURLAsset else { + precondition( + false, + "Only assets with URLs can be uploaded" + ) + } + self.init( input: UploadInput( - asset: inputAsset, + asset: urlAsset, info: UploadInfo( uploadURL: uploadURL, options: options diff --git a/Sources/MuxUploadSDK/PublicAPI/DirectUpload.swift b/Sources/MuxUploadSDK/PublicAPI/DirectUpload.swift index 9736cdbf..b84033c1 100644 --- a/Sources/MuxUploadSDK/PublicAPI/DirectUpload.swift +++ b/Sources/MuxUploadSDK/PublicAPI/DirectUpload.swift @@ -24,32 +24,28 @@ public typealias DirectUploadResult = Result () - /** - If set will be notified of a change to a new input status - */ + /// Sets a handler that gets notified when the status of + /// the upload changes public var inputStatusHandler: InputStatusHandler? - /** - Confirms upload if input standardization did not succeed - */ + /// Confirms if upload should proceed when input + /// standardization does not succeed public typealias NonStandardInputHandler = () -> Bool - /** - If set will be executed by the SDK when input standardization - hadn't succeeded, return to continue the upload - or return to cancel the upload - */ + /// Sets a handler that will be executed by the SDK + /// when input standardization doesn't succeed. Return + /// to continue the upload public var nonStandardInputHandler: NonStandardInputHandler? private let manageBySDK: Bool @@ -170,76 +189,21 @@ public final class DirectUpload { internal var fileWorker: ChunkedFileUploader? - /** - Represents the state of an upload in progress. - */ + /// Represents the state of an upload when it is being + /// sent to Mux over the network public struct TransportStatus : Sendable, Hashable { - /** - The percentage of file bytes received by the server - accepting the upload - */ + /// The percentage of file bytes received at the + /// upload destination public let progress: Progress? - /** - A timestamp indicating when this status was generated - */ + /// Timestamp from when this update was generated public let updatedTime: TimeInterval - /** - The start time of the upload, nil if the upload - has never been started - */ + /// The start time of the upload, nil if the upload + /// has never been started public let startTime: TimeInterval? - /** - Indicates if the upload has been paused - */ + /// Indicates if the upload has been paused public let isPaused: Bool } - /// Initializes a DirectUpload from a local file URL with - /// the given configuration - /// - Parameters: - /// - uploadURL: the URL of your direct upload, see - /// the [direct upload guide](https://docs.mux.com/api-reference#video/operation/create-direct-upload) - /// - videoFileURL: the file:// URL of the upload - /// input - /// - chunkSize: the size of chunks when uploading, - /// at least 8M is recommended - /// - retriesPerChunk: number of retry attempts for - /// a failed chunk request - /// - inputStandardization: enable or disable input - /// standardization by the SDK locally - /// - eventTracking: options to opt out of event - /// tracking - @available(*, deprecated, renamed: "init(uploadURL:inputFileURL:options:)") - public convenience init( - uploadURL: URL, - videoFileURL: URL, - chunkSize: Int = 8 * 1024 * 1024, // Google recommends at least 8M - retriesPerChunk: Int = 3, - inputStandardization: DirectUploadOptions.InputStandardization = .default, - eventTracking: DirectUploadOptions.EventTracking = .default - ) { - let asset = AVAsset(url: videoFileURL) - self.init( - input: UploadInput( - asset: asset, - info: UploadInfo( - id: UUID().uuidString, - uploadURL: uploadURL, - options: DirectUploadOptions( - inputStandardization: inputStandardization, - transport: DirectUploadOptions.Transport( - chunkSizeInBytes: chunkSize, - retryLimitPerChunk: retriesPerChunk - ), - eventTracking: eventTracking - ) - ) - ), - uploadManager: .shared, - inputInspector: .shared - ) - } - /// Initializes a DirectUpload from a local file URL /// /// - Parameters: @@ -255,7 +219,7 @@ public final class DirectUpload { inputFileURL: URL, options: DirectUploadOptions = .default ) { - let asset = AVAsset( + let asset = AVURLAsset( url: inputFileURL ) self.init( @@ -305,6 +269,7 @@ public final class DirectUpload { self.init( input: UploadInput( status: .uploadInProgress( + AVURLAsset(url: uploader.inputFileURL), uploader.uploadInfo, TransportStatus( progress: uploader.currentState.progress ?? Progress(), @@ -332,45 +297,38 @@ public final class DirectUpload { ) } - /** - Handles state updates for this upload in your app. - */ + + /// Handles updates when upload data is sent over the network public typealias StateHandler = (TransportStatus) -> Void - /** - If set will receive progress updates for this upload, - updates will not be received less than 100ms apart - */ + /// Sets handler that receives progress updates when + /// the upload transits over the network. Updates will + /// not be received less than 100ms apart public var progressHandler: StateHandler? - /** - Details about a ``DirectUpload`` after it successfully finished - */ + /// Details of a successfully completed ``DirectUpload`` public struct SuccessDetails : Sendable, Hashable { public let finalState: TransportStatus } - /** - The current status of the upload. This object is updated periodically. To listen for changes, use ``progressHandler`` - */ + /// Current status of the upload while it is in transit. + /// To listen for changes, use ``progressHandler`` + /// - SeeAlso: progressHandler public var uploadStatus: TransportStatus? { input.transportStatus } - /** - Handles the final result of this upload in your app - */ + /// Handles completion of the uploads execution + /// - SeeAlso: resultHandler public typealias ResultHandler = (DirectUploadResult) -> Void - /** - If set will be notified when this upload is successfully - completed or if there's an error - */ + /// Sets handler that is notified when the upload completes + /// execution or if it fails due to an error + /// - SeeAlso: ResultHandler public var resultHandler: ResultHandler? - - /** - True if this upload is currently in progress and not paused - */ + + /// Indicates if the upload is currently in progress + /// and not paused public var inProgress: Bool { if case InputStatus.transportInProgress = inputStatus { return true @@ -378,10 +336,8 @@ public final class DirectUpload { return false } } - - /** - True if this upload was completed - */ + + /// Indicates if the upload has been completed public var complete: Bool { if case InputStatus.finished = inputStatus { return true @@ -398,25 +354,20 @@ public final class DirectUpload { return fileWorker?.inputFileURL } - /** - The remote endpoint that this object uploads to - */ + /// URL of the remote upload destination public var uploadURL: URL { return uploadInfo.uploadURL } - // TODO: Computed Properties for some other UploadInfo properties - - /** - Begins the upload. You can control what happens when the upload is already started. If `forceRestart` is true, the upload will be restarted. Otherwise, nothing will happen. The default is not to restart - */ - public func start(forceRestart: Bool = false) { - - let videoFile = (input.sourceAsset as! AVURLAsset).url + /// Starts the upload. + /// - Parameter forceRestart: if true, the upload will be + /// restarted. If false the upload will resume from where + /// it left off if paused, otherwise the upload will change. + public func start(forceRestart: Bool = false) { if self.manageBySDK { // See if there's anything in progress already fileWorker = uploadManager.findChunkedFileUploader( - inputFileURL: videoFile + inputFileURL: input.sourceAsset.url ) } if fileWorker != nil && !forceRestart { @@ -435,17 +386,17 @@ public final class DirectUpload { if case UploadInput.Status.ready = input.status { input.status = .started(input.sourceAsset, uploadInfo) - startInspection(videoFile: videoFile) + startInspection(sourceAsset: input.sourceAsset) } else if forceRestart { cancel() } } func startInspection( - videoFile: URL + sourceAsset: AVURLAsset ) { if !uploadInfo.options.inputStandardization.isRequested { - startNetworkTransport(videoFile: videoFile) + startNetworkTransport(videoFile: sourceAsset.url) } else { let inputStandardizationStartTime = Date() let reporter = Reporter.shared @@ -457,133 +408,239 @@ public final class DirectUpload { // instead throw an error since upload // will likely fail let inputSize = (try? FileManager.default.fileSizeOfItem( - atPath: videoFile.path + atPath: input.sourceAsset.url.absoluteString )) ?? 0 input.status = .underInspection(input.sourceAsset, uploadInfo) inputInspector.performInspection( - sourceInput: input.sourceAsset - ) { inspectionResult in + sourceInput: input.sourceAsset, + maximumResolution: uploadInfo.options.inputStandardization.maximumResolution + ) { inspectionResult, inputDuration, inspectionError in self.inspectionResult = inspectionResult - switch inspectionResult { - case .inspectionFailure: - // Request upload confirmation - // before proceeding. If handler unset, - // by default do not cancel upload if - // input standardization fails - let shouldCancelUpload = self.nonStandardInputHandler?() ?? false - - reporter.reportUploadInputStandardizationFailure( - errorDescription: "Input inspection failure", - inputDuration: inspectionResult.sourceInputDuration.seconds, + switch (inspectionResult, inspectionError) { + case (.none, .none): + // Corner case + self.handleInspectionFailure( + inspectionError: UploadInputInspectionError.inspectionFailure, + inputDuration: inputDuration, inputSize: inputSize, - nonStandardInputReasons: [], - options: self.uploadInfo.options, - standardizationEndTime: Date(), - standardizationStartTime: inputStandardizationStartTime, - uploadCanceled: shouldCancelUpload, - uploadURL: self.uploadURL + inputStandardizationStartTime: inputStandardizationStartTime, + sourceAsset: sourceAsset ) + case (.none, .some(let error)): + self.handleInspectionFailure( + inspectionError: error, + inputDuration: inputDuration, + inputSize: inputSize, + inputStandardizationStartTime: inputStandardizationStartTime, + sourceAsset: sourceAsset + ) + case (.some(let result), .none): + if result.isStandardInput { - if !shouldCancelUpload { - self.startNetworkTransport( - videoFile: videoFile - ) - } else { - self.fileWorker?.cancel() - self.uploadManager.acknowledgeUpload(id: self.id) - self.input.processUploadCancellation() - } - case .standard: - self.startNetworkTransport(videoFile: videoFile) - case .nonstandard( - let reasons, _ - ): - print(""" - Detected Nonstandard Reasons + if result.rescalingDetails.needsRescaling { + SDKLogger.logger?.debug( + "Detected Input Needs Rescaling" + ) - \(dump(reasons, indent: 4)) + // TODO: inject Date() for testing purposes + let outputFileName = "upload-\(Date().timeIntervalSince1970)" - """ - ) + let outputDirectory = FileManager.default.temporaryDirectory + let outputURL = URL( + fileURLWithPath: outputFileName, + relativeTo: outputDirectory + ) - // TODO: inject Date() for testing purposes - let outputFileName = "upload-\(Date().timeIntervalSince1970)" + self.inputStandardizer.standardize( + id: self.id, + sourceAsset: sourceAsset, + rescalingDetails: result.rescalingDetails, + outputURL: outputURL + ) { sourceAsset, standardizedAsset, error in + + if let _ = error { + // Request upload confirmation + // before proceeding. If handler unset, + // by default do not cancel upload if + // input standardization fails + let shouldCancelUpload = self.nonStandardInputHandler?() ?? false + + if !shouldCancelUpload { + self.startNetworkTransport( + videoFile: sourceAsset.url + ) + } else { + self.fileWorker?.cancel() + self.uploadManager.acknowledgeUpload(id: self.id) + self.input.processUploadCancellation() + } + } else { + self.startNetworkTransport( + videoFile: outputURL, + duration: inputDuration + ) + } + + self.inputStandardizer.acknowledgeCompletion(id: self.id) + } - let outputDirectory = FileManager.default.temporaryDirectory - let outputURL = URL( - fileURLWithPath: outputFileName, - relativeTo: outputDirectory - ) - let maximumResolution = self.input - .uploadInfo - .options - .inputStandardization - .maximumResolution - - self.inputStandardizer.standardize( - id: self.id, - sourceAsset: AVAsset(url: videoFile), - maximumResolution: maximumResolution, - outputURL: outputURL - ) { sourceAsset, standardizedAsset, error in - - if let error { - // Request upload confirmation - // before proceeding. If handler unset, - // by default do not cancel upload if - // input standardization fails - let shouldCancelUpload = self.nonStandardInputHandler?() ?? false - - reporter.reportUploadInputStandardizationFailure( - errorDescription: error.localizedDescription, - inputDuration: inspectionResult.sourceInputDuration.seconds, - inputSize: inputSize, - nonStandardInputReasons: reasons, - options: self.uploadInfo.options, - standardizationEndTime: Date(), - standardizationStartTime: inputStandardizationStartTime, - uploadCanceled: shouldCancelUpload, - uploadURL: self.uploadURL + } else { + self.startNetworkTransport( + videoFile: sourceAsset.url ) + } + } else { + SDKLogger.logger?.debug( + """ + Detected Nonstandard Reasons - if !shouldCancelUpload { - self.startNetworkTransport( - videoFile: videoFile + \(dump(result.nonStandardInputReasons, indent: 4)) + + """ + ) + + // TODO: inject Date() for testing purposes + let outputFileName = "upload-\(Date().timeIntervalSince1970)" + + let outputDirectory = FileManager.default.temporaryDirectory + let outputURL = URL( + fileURLWithPath: outputFileName, + relativeTo: outputDirectory + ) + + self.inputStandardizer.standardize( + id: self.id, + sourceAsset: sourceAsset, + rescalingDetails: result.rescalingDetails, + outputURL: outputURL + ) { sourceAsset, standardizedAsset, error in + + if let error { + // Request upload confirmation + // before proceeding. If handler unset, + // by default do not cancel upload if + // input standardization fails + let shouldCancelUpload = self.nonStandardInputHandler?() ?? false + + reporter.reportUploadInputStandardizationFailure( + errorDescription: error.localizedDescription, + inputDuration: inputDuration.seconds, + inputSize: inputSize, + nonStandardInputReasons: result.nonStandardInputReasons, + options: self.uploadInfo.options, + standardizationEndTime: Date(), + standardizationStartTime: inputStandardizationStartTime, + uploadCanceled: shouldCancelUpload, + uploadURL: self.uploadURL ) + + if !shouldCancelUpload { + self.startNetworkTransport( + videoFile: sourceAsset.url + ) + } else { + self.fileWorker?.cancel() + self.uploadManager.acknowledgeUpload(id: self.id) + self.input.processUploadCancellation() + } } else { - self.fileWorker?.cancel() - self.uploadManager.acknowledgeUpload(id: self.id) - self.input.processUploadCancellation() + reporter.reportUploadInputStandardizationSuccess( + inputDuration: inputDuration.seconds, + inputSize: inputSize, + options: self.uploadInfo.options, + nonStandardInputReasons: result.nonStandardInputReasons, + standardizationEndTime: Date(), + standardizationStartTime: inputStandardizationStartTime, + uploadURL: self.uploadURL + ) + + self.startNetworkTransport( + videoFile: outputURL, + duration: inputDuration + ) } - } else { - reporter.reportUploadInputStandardizationSuccess( - inputDuration: inspectionResult.sourceInputDuration.seconds, - inputSize: inputSize, - options: self.uploadInfo.options, - nonStandardInputReasons: reasons, - standardizationEndTime: Date(), - standardizationStartTime: inputStandardizationStartTime, - uploadURL: self.uploadURL - ) - self.startNetworkTransport( - videoFile: outputURL, - duration: inspectionResult.sourceInputDuration - ) + self.inputStandardizer.acknowledgeCompletion(id: self.id) } - - self.inputStandardizer.acknowledgeCompletion(id: self.id) } + case (.some(_), .some(let error)): + self.handleInspectionFailure( + inspectionError: error, + inputDuration: inputDuration, + inputSize: inputSize, + inputStandardizationStartTime: inputStandardizationStartTime, + sourceAsset: sourceAsset + ) } } } } + func handleInspectionFailure( + inspectionError: Error, + inputDuration: CMTime, + inputSize: UInt64, + inputStandardizationStartTime: Date, + sourceAsset: AVURLAsset + ) { + let reporter = Reporter.shared + // Request upload confirmation + // before proceeding. If handler unset, + // by default do not cancel upload if + // input standardization fails + let shouldCancelUpload = self.nonStandardInputHandler?() ?? false + + reporter.reportUploadInputStandardizationFailure( + errorDescription: "Input inspection failure", + inputDuration: inputDuration.seconds, + inputSize: inputSize, + nonStandardInputReasons: [], + options: self.uploadInfo.options, + standardizationEndTime: Date(), + standardizationStartTime: inputStandardizationStartTime, + uploadCanceled: shouldCancelUpload, + uploadURL: self.uploadURL + ) + + if !shouldCancelUpload { + self.startNetworkTransport( + videoFile: sourceAsset.url + ) + } else { + self.fileWorker?.cancel() + self.uploadManager.acknowledgeUpload(id: self.id) + self.input.processUploadCancellation() + } + } + + func readyForTransport() -> Bool { + switch inputStatus { + case .ready: + return false + case .started: + return true + case .preparing: + return true + case .awaitingConfirmation: + return true + case .transportInProgress: + return false + case .paused: + return false + case .finished: + return false + } + } + func startNetworkTransport( videoFile: URL ) { + guard readyForTransport() else { + return + } + let completedUnitCount = UInt64(uploadStatus?.progress?.completedUnitCount ?? 0) let fileWorker = ChunkedFileUploader( @@ -615,6 +672,11 @@ public final class DirectUpload { videoFile: URL, duration: CMTime ) { + + guard readyForTransport() else { + return + } + let completedUnitCount = UInt64(uploadStatus?.progress?.completedUnitCount ?? 0) let fileWorker = ChunkedFileUploader( @@ -642,19 +704,21 @@ public final class DirectUpload { inputStatusHandler?(inputStatus) } - /** - Suspends the execution of this upload. Temp files and state will not be changed. The upload will remain paused in this state - even after process death. - Use ``start(forceRestart:)``, passing `false` to start the process over where it was left. - Use ``cancel()`` to remove this upload completely - */ + + /// Suspends upload execution. Temporary files will be + /// kept unchanged and the upload can be resumed by calling + /// ``start(forceRestart:)`` with forceRestart set to `false` + /// to resume the upload from where it left off. + /// + /// Call ``cancel()`` to permanently halt the upload. + /// - SeeAlso cancel() public func pause() { fileWorker?.pause() } - /** - Cancels an ongoing download. State and Delegates will be cleared. Your delegates will recieve no further calls - */ + /// Cancels an upload that has already been started. + /// Any delegates or handlers set prior to this will + /// receive no further updates. public func cancel() { fileWorker?.cancel() uploadManager.acknowledgeUpload(id: id) @@ -719,6 +783,7 @@ public final class DirectUpload { ) } else { input.status = .uploadInProgress( + input.sourceAsset, input.uploadInfo, status ) @@ -825,9 +890,9 @@ extension DirectUpload { } } -/** - An fatal error that ocurred during the upload process. The last-known state of the upload is available, as well as the Error that stopped the upload - */ +/// An unrecoverable error occurring while the upload was +/// executing The last-known state of the upload is available, +/// as well as the Error that stopped the upload public struct DirectUploadError : Error { /// Represents the possible error cases from a ``DirectUpload`` public enum Kind : Int { diff --git a/Sources/MuxUploadSDK/PublicAPI/DirectUploadManager.swift b/Sources/MuxUploadSDK/PublicAPI/DirectUploadManager.swift index 76edfaa5..c92f8e78 100644 --- a/Sources/MuxUploadSDK/PublicAPI/DirectUploadManager.swift +++ b/Sources/MuxUploadSDK/PublicAPI/DirectUploadManager.swift @@ -62,9 +62,10 @@ public final class DirectUploadManager { return nil } - /// Returns all uploads currently-managed uploads. - /// Uploads are managed while in-progress or compelted. - /// Uploads become un-managed when canceled, or if the process dies after they complete + /// Returns all currently-managed uploads that are + /// in-progress or completed. Uploads that are canceled + /// or uploads that completed before the most recent + /// application termination are omitted public func allManagedDirectUploads() -> [DirectUpload] { // Sort upload list for consistent ordering return Array(uploadsByID.values.map(\.upload)) @@ -144,13 +145,13 @@ public final class DirectUploadManager { uploadsByID.updateValue(UploadStorage(upload: upload), forKey: upload.id) fileWorker.addDelegate(withToken: UUID().uuidString, uploaderDelegate) + self.notifyDelegates() Task.detached { await self.uploadActor.updateUpload( fileWorker.uploadInfo, fileInputURL: fileWorker.inputFileURL, withUpdate: fileWorker.currentState ) - self.notifyDelegates() } } @@ -169,13 +170,15 @@ public final class DirectUploadManager { /// The shared instance of this object that should be used public static let shared = DirectUploadManager() - internal init() { } private struct FileUploaderDelegate : ChunkedFileUploaderDelegate { let manager: DirectUploadManager - func chunkedFileUploader(_ uploader: ChunkedFileUploader, stateUpdated state: ChunkedFileUploader.InternalUploadState) { - Task.detached { + func chunkedFileUploader( + _ uploader: ChunkedFileUploader, + stateUpdated state: ChunkedFileUploader.InternalUploadState + ) { + let _ = Task.detached { await manager.uploadActor.updateUpload( uploader.uploadInfo, fileInputURL: uploader.inputFileURL, @@ -184,8 +187,12 @@ public final class DirectUploadManager { manager.notifyDelegates() } switch state { - case .success(_), .canceled: manager.acknowledgeUpload(id: uploader.uploadInfo.id) - default: do { } + case .canceled: + manager.acknowledgeUpload( + id: uploader.uploadInfo.id + ) + default: + break } } } diff --git a/Sources/MuxUploadSDK/PublicAPI/Options/DirectUploadOptions.swift b/Sources/MuxUploadSDK/PublicAPI/Options/DirectUploadOptions.swift index 61b4787c..a784b2bf 100644 --- a/Sources/MuxUploadSDK/PublicAPI/Options/DirectUploadOptions.swift +++ b/Sources/MuxUploadSDK/PublicAPI/Options/DirectUploadOptions.swift @@ -88,21 +88,29 @@ public struct DirectUploadOptions { /// format. ``true`` by default public var isRequested: Bool = true - /// Preset to control the resolution of the standard - /// input. + /// Preset to control the maximum resolution of a + /// standardized input. Inputs with smaller dimensions + /// won't be scaled up. /// - /// See ``DirectUploadOptions.InputStandardization.maximumResolution`` - /// for more details. public enum MaximumResolution { - /// Preset standardized direct upload input to the SDK - /// default standard resolution of 1920x1080 (1080p). + /// By default the standardized input will be + /// scaled down to 1920x1080 (1080p) from a larger + /// size. Inputs with smaller dimensions won't be + /// scaled up. case `default` - /// Limit standardized direct upload input resolution to - /// 1280x720 (720p). + /// The standardized input will be scaled down + /// to 1280x720 (720p) from a larger size. Inputs + /// with smaller dimensions won't be scaled up. case preset1280x720 // 720p - /// Limit standardized direct upload input resolution to - /// 1920x1080 (1080p). + /// The standardized input will be scaled down + /// to 1920x1080 (1080p) from a larger size. Inputs + /// with smaller dimensions won't be scaled up. case preset1920x1080 // 1080p + /// The standardized input will be scaled down + /// to 3840x2160 (2160p/4K) from a larger size. + /// Inputs with smaller dimensions won't be scaled + /// up. + case preset3840x2160 // 2160p } /// The maximum resolution of the standardized direct @@ -311,6 +319,8 @@ extension DirectUploadOptions.InputStandardization.MaximumResolution: CustomStri return "preset1280x720" case .preset1920x1080: return "preset1920x1080" + case .preset3840x2160: + return "preset3840x2160" case .default: return "default" } diff --git a/Sources/MuxUploadSDK/PublicAPI/SemanticVersion.swift b/Sources/MuxUploadSDK/PublicAPI/SemanticVersion.swift index 010add56..15c894ca 100644 --- a/Sources/MuxUploadSDK/PublicAPI/SemanticVersion.swift +++ b/Sources/MuxUploadSDK/PublicAPI/SemanticVersion.swift @@ -10,9 +10,9 @@ import Foundation /// Version information about the SDK public struct SemanticVersion { /// Major version component. - public static let major = 0 + public static let major = 1 /// Minor version component. - public static let minor = 7 + public static let minor = 0 /// Patch version component. public static let patch = 0 diff --git a/Sources/MuxUploadSDK/Upload/UploadInfo.swift b/Sources/MuxUploadSDK/Upload/UploadInfo.swift index 8c7661dc..020f6120 100644 --- a/Sources/MuxUploadSDK/Upload/UploadInfo.swift +++ b/Sources/MuxUploadSDK/Upload/UploadInfo.swift @@ -29,11 +29,3 @@ struct UploadInfo : Codable { } extension UploadInfo: Equatable { } - -extension UploadInfo { - func sourceAsset() -> AVAsset { - AVAsset( - url: uploadURL - ) - } -} diff --git a/Tests/MuxUploadSDKTests/Test Doubles/DummyError.swift b/Tests/MuxUploadSDKTests/Helpers/DummyError.swift similarity index 100% rename from Tests/MuxUploadSDKTests/Test Doubles/DummyError.swift rename to Tests/MuxUploadSDKTests/Helpers/DummyError.swift diff --git a/Tests/MuxUploadSDKTests/Test Doubles/FakeUploadsFile.swift b/Tests/MuxUploadSDKTests/Helpers/FakeUploadsFile.swift similarity index 100% rename from Tests/MuxUploadSDKTests/Test Doubles/FakeUploadsFile.swift rename to Tests/MuxUploadSDKTests/Helpers/FakeUploadsFile.swift diff --git a/Tests/MuxUploadSDKTests/Helpers/MockUploadInputInspector.swift b/Tests/MuxUploadSDKTests/Helpers/MockUploadInputInspector.swift new file mode 100644 index 00000000..5662deb5 --- /dev/null +++ b/Tests/MuxUploadSDKTests/Helpers/MockUploadInputInspector.swift @@ -0,0 +1,51 @@ +// +// MockUploadInputInspector.swift +// + +import AVFoundation +import Foundation + +@testable import MuxUploadSDK + +class MockUploadInputInspector: UploadInputInspector { + + static let alwaysStandard: MockUploadInputInspector = MockUploadInputInspector() + + static let alwaysFailing: MockUploadInputInspector = MockUploadInputInspector( + mockInspectionResult: UploadInputFormatInspectionResult( + nonStandardInputReasons: [], + rescalingDetails: .init() + ), + mockInspectionError: UploadInputInspectionError.inspectionFailure + ) + + var mockInspectionError: Error? + var mockInspectionResult: UploadInputFormatInspectionResult + var duration: CMTime + + init() { + self.mockInspectionResult = UploadInputFormatInspectionResult( + nonStandardInputReasons: [], + rescalingDetails: .init() + ) + self.duration = .zero + } + + init( + mockInspectionResult: UploadInputFormatInspectionResult, + mockInspectionError: Error? = nil + ) { + self.mockInspectionResult = mockInspectionResult + self.mockInspectionError = mockInspectionError + self.duration = .zero + } + + func performInspection( + sourceInput: AVAsset, + maximumResolution: DirectUploadOptions.InputStandardization.MaximumResolution, + completionHandler: @escaping UploadInputInspectionCompletionHandler + ) { + completionHandler(mockInspectionResult, duration, mockInspectionError) + } + +} diff --git a/Tests/MuxUploadSDKTests/Test Doubles/PersistenceEntry+Fixtures.swift b/Tests/MuxUploadSDKTests/Helpers/PersistenceEntry+Fixtures.swift similarity index 100% rename from Tests/MuxUploadSDKTests/Test Doubles/PersistenceEntry+Fixtures.swift rename to Tests/MuxUploadSDKTests/Helpers/PersistenceEntry+Fixtures.swift diff --git a/Tests/MuxUploadSDKTests/Test Doubles/StubbedChunkedFile.swift b/Tests/MuxUploadSDKTests/Helpers/StubbedChunkedFile.swift similarity index 100% rename from Tests/MuxUploadSDKTests/Test Doubles/StubbedChunkedFile.swift rename to Tests/MuxUploadSDKTests/Helpers/StubbedChunkedFile.swift diff --git a/Tests/MuxUploadSDKTests/Test Doubles/UploadInput+Fixtures.swift b/Tests/MuxUploadSDKTests/Helpers/UploadInput+Fixtures.swift similarity index 93% rename from Tests/MuxUploadSDKTests/Test Doubles/UploadInput+Fixtures.swift rename to Tests/MuxUploadSDKTests/Helpers/UploadInput+Fixtures.swift index 0fec1649..a261b3b4 100644 --- a/Tests/MuxUploadSDKTests/Test Doubles/UploadInput+Fixtures.swift +++ b/Tests/MuxUploadSDKTests/Helpers/UploadInput+Fixtures.swift @@ -19,7 +19,7 @@ extension UploadInput { URL(string: "file://path/to/dummy/file/") ) - let uploadInputAsset = AVAsset( + let uploadInputAsset = AVURLAsset( url: videoInputURL ) @@ -43,7 +43,7 @@ extension UploadInput { URL(string: "file://path/to/dummy/file/") ) - let uploadInputAsset = AVAsset( + let uploadInputAsset = AVURLAsset( url: videoInputURL ) diff --git a/Tests/MuxUploadSDKTests/Test Doubles/MockUploadInputInspector.swift b/Tests/MuxUploadSDKTests/Test Doubles/MockUploadInputInspector.swift deleted file mode 100644 index 3aaaed9b..00000000 --- a/Tests/MuxUploadSDKTests/Test Doubles/MockUploadInputInspector.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// MockUploadInputInspector.swift -// - -import AVFoundation -import Foundation - -@testable import MuxUploadSDK - -class MockUploadInputInspector: UploadInputInspector { - - static let alwaysStandard: MockUploadInputInspector = MockUploadInputInspector( - mockInspectionResult: .standard(duration: .zero) - ) - - static let alwaysFailing: MockUploadInputInspector = MockUploadInputInspector( - mockInspectionResult: .inspectionFailure(duration: .zero) - ) - - var mockInspectionResult: UploadInputFormatInspectionResult - - init() { - self.mockInspectionResult = .standard(duration: .zero) - } - - init( - mockInspectionResult: UploadInputFormatInspectionResult - ) { - self.mockInspectionResult = mockInspectionResult - } - - func performInspection( - sourceInput: AVAsset, - completionHandler: @escaping (UploadInputFormatInspectionResult) -> () - ) { - completionHandler(mockInspectionResult) - } - -} diff --git a/Tests/MuxUploadSDKTests/UploadManagerTests/UploadManagerTests.swift b/Tests/MuxUploadSDKTests/UploadManagerTests/UploadManagerTests.swift index 01b4eea3..47c4e7ab 100644 --- a/Tests/MuxUploadSDKTests/UploadManagerTests/UploadManagerTests.swift +++ b/Tests/MuxUploadSDKTests/UploadManagerTests/UploadManagerTests.swift @@ -34,7 +34,7 @@ class UploadManagerTests: XCTestCase { let upload = DirectUpload( input: UploadInput( - asset: AVAsset(url: videoInputURL), + asset: AVURLAsset(url: videoInputURL), info: UploadInfo( uploadURL: uploadURL, options: .inputStandardizationSkipped @@ -45,7 +45,7 @@ class UploadManagerTests: XCTestCase { let duplicateUpload = DirectUpload( input: UploadInput( - asset: AVAsset(url: videoInputURL), + asset: AVURLAsset(url: videoInputURL), info: UploadInfo( uploadURL: uploadURL, options: .inputStandardizationSkipped diff --git a/scripts/run-unit-tests.sh b/scripts/run-unit-tests.sh index e474b425..e87c6b79 100755 --- a/scripts/run-unit-tests.sh +++ b/scripts/run-unit-tests.sh @@ -17,9 +17,9 @@ then exit 1 fi -echo "▸ Selecting Xcode 15" +echo "▸ Selecting Xcode 15.4" -sudo xcode-select -s /Applications/Xcode_15.0.app/Contents/Developer +sudo xcode-select -s /Applications/Xcode_15.4.app/Contents/Developer echo "▸ Using Xcode Version: ${XCODE}" @@ -39,6 +39,6 @@ echo "▸ Test ${SCHEME}" xcodebuild clean test \ -scheme $SCHEME \ - -destination 'platform=iOS Simulator,OS=17.0.1,name=iPhone 15' \ - -sdk iphonesimulator17.0 \ + -destination 'platform=iOS Simulator,OS=17.5,name=iPhone 15' \ + -sdk iphonesimulator17.5 \ | xcbeautify