├── .DS_Store ├── .gitattributes ├── Convierto.xcodeproj └── project.pbxproj ├── Convierto ├── App │ ├── AppDelegate.swift │ └── ConviertoApp.swift ├── Convierto.entitlements ├── Extensions │ └── UTType+Extensions.swift ├── Info.plist ├── Processor │ ├── AudioProcessor.swift │ ├── AudioProcessorConfig.swift │ ├── BaseConverter.swift │ ├── CacheManager.swift │ ├── ConversionCoordinator.swift │ ├── ConversionError.swift │ ├── ConversionStrategy.swift │ ├── DocumentProcessor.swift │ ├── FileProcessor.swift │ ├── ImageProcessor.swift │ ├── ProcessorFactory.swift │ └── VideoProcessor.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── AppIcon-1024.png │ │ │ ├── AppIcon-128.png │ │ │ ├── AppIcon-16.png │ │ │ ├── AppIcon-256 1.png │ │ │ ├── AppIcon-256.png │ │ │ ├── AppIcon-32 1.png │ │ │ ├── AppIcon-32.png │ │ │ ├── AppIcon-512 1.png │ │ │ ├── AppIcon-512.png │ │ │ ├── AppIcon-64.png │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ConversionSettings.swift │ ├── FileDropDelegate.swift │ └── UpdateChecker.swift ├── UI Components │ ├── ButtonGroup.swift │ ├── GlassButtonStyle.swift │ ├── TitleBarAccessory.swift │ ├── VisualEffectBlur.swift │ └── WindowAccessor.swift ├── Utilities │ ├── AudioVisualizer.swift │ ├── ConversionMetadata.swift │ ├── FileValidator.swift │ ├── GraphicsContextManager.swift │ ├── LoggerSetup.swift │ ├── MemoryPressureHandler.swift │ ├── NotificationNames.swift │ ├── ProgressTracker.swift │ ├── ResourceManager.swift │ ├── ResourceMonitor.swift │ ├── ResourcePool.swift │ ├── ResourceType.swift │ ├── SendableItemProviderWrapper.swift │ ├── SendableWrapper.swift │ └── WaveformStyle.swift └── Views │ ├── Components │ ├── CommandPalette.swift │ └── FormatButton.swift │ ├── ContentView.swift │ ├── DropZoneView.swift │ ├── FormatSelectorMenu.swift │ ├── MenuBarView.swift │ ├── MultiFileProcessingView.swift │ ├── ProcessingView.swift │ └── ResultView.swift ├── LICENSE └── README.md /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/.DS_Store -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /Convierto.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | C82F044E2CCB3DD20012C07B /* Convierto.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Convierto.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 14 | C8A16E302CE0FC6D00F427B2 /* Exceptions for "Convierto" folder in "Convierto" target */ = { 15 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 16 | membershipExceptions = ( 17 | Info.plist, 18 | ); 19 | target = C82F044D2CCB3DD20012C07B /* Convierto */; 20 | }; 21 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 22 | 23 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 24 | C82F04502CCB3DD20012C07B /* Convierto */ = { 25 | isa = PBXFileSystemSynchronizedRootGroup; 26 | exceptions = ( 27 | C8A16E302CE0FC6D00F427B2 /* Exceptions for "Convierto" folder in "Convierto" target */, 28 | ); 29 | path = Convierto; 30 | sourceTree = ""; 31 | }; 32 | /* End PBXFileSystemSynchronizedRootGroup section */ 33 | 34 | /* Begin PBXFrameworksBuildPhase section */ 35 | C82F044B2CCB3DD20012C07B /* Frameworks */ = { 36 | isa = PBXFrameworksBuildPhase; 37 | buildActionMask = 2147483647; 38 | files = ( 39 | ); 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXFrameworksBuildPhase section */ 43 | 44 | /* Begin PBXGroup section */ 45 | C82F04452CCB3DD20012C07B = { 46 | isa = PBXGroup; 47 | children = ( 48 | C82F04502CCB3DD20012C07B /* Convierto */, 49 | C82F044F2CCB3DD20012C07B /* Products */, 50 | ); 51 | sourceTree = ""; 52 | }; 53 | C82F044F2CCB3DD20012C07B /* Products */ = { 54 | isa = PBXGroup; 55 | children = ( 56 | C82F044E2CCB3DD20012C07B /* Convierto.app */, 57 | ); 58 | name = Products; 59 | sourceTree = ""; 60 | }; 61 | /* End PBXGroup section */ 62 | 63 | /* Begin PBXNativeTarget section */ 64 | C82F044D2CCB3DD20012C07B /* Convierto */ = { 65 | isa = PBXNativeTarget; 66 | buildConfigurationList = C82F045D2CCB3DD40012C07B /* Build configuration list for PBXNativeTarget "Convierto" */; 67 | buildPhases = ( 68 | C82F044A2CCB3DD20012C07B /* Sources */, 69 | C82F044B2CCB3DD20012C07B /* Frameworks */, 70 | C82F044C2CCB3DD20012C07B /* Resources */, 71 | ); 72 | buildRules = ( 73 | ); 74 | dependencies = ( 75 | ); 76 | fileSystemSynchronizedGroups = ( 77 | C82F04502CCB3DD20012C07B /* Convierto */, 78 | ); 79 | name = Convierto; 80 | packageProductDependencies = ( 81 | ); 82 | productName = Convierto; 83 | productReference = C82F044E2CCB3DD20012C07B /* Convierto.app */; 84 | productType = "com.apple.product-type.application"; 85 | }; 86 | /* End PBXNativeTarget section */ 87 | 88 | /* Begin PBXProject section */ 89 | C82F04462CCB3DD20012C07B /* Project object */ = { 90 | isa = PBXProject; 91 | attributes = { 92 | BuildIndependentTargetsInParallel = 1; 93 | LastSwiftUpdateCheck = 1600; 94 | LastUpgradeCheck = 1610; 95 | TargetAttributes = { 96 | C82F044D2CCB3DD20012C07B = { 97 | CreatedOnToolsVersion = 16.0; 98 | }; 99 | }; 100 | }; 101 | buildConfigurationList = C82F04492CCB3DD20012C07B /* Build configuration list for PBXProject "Convierto" */; 102 | developmentRegion = en; 103 | hasScannedForEncodings = 0; 104 | knownRegions = ( 105 | en, 106 | Base, 107 | ); 108 | mainGroup = C82F04452CCB3DD20012C07B; 109 | minimizedProjectReferenceProxies = 1; 110 | preferredProjectObjectVersion = 77; 111 | productRefGroup = C82F044F2CCB3DD20012C07B /* Products */; 112 | projectDirPath = ""; 113 | projectRoot = ""; 114 | targets = ( 115 | C82F044D2CCB3DD20012C07B /* Convierto */, 116 | ); 117 | }; 118 | /* End PBXProject section */ 119 | 120 | /* Begin PBXResourcesBuildPhase section */ 121 | C82F044C2CCB3DD20012C07B /* Resources */ = { 122 | isa = PBXResourcesBuildPhase; 123 | buildActionMask = 2147483647; 124 | files = ( 125 | ); 126 | runOnlyForDeploymentPostprocessing = 0; 127 | }; 128 | /* End PBXResourcesBuildPhase section */ 129 | 130 | /* Begin PBXSourcesBuildPhase section */ 131 | C82F044A2CCB3DD20012C07B /* Sources */ = { 132 | isa = PBXSourcesBuildPhase; 133 | buildActionMask = 2147483647; 134 | files = ( 135 | ); 136 | runOnlyForDeploymentPostprocessing = 0; 137 | }; 138 | /* End PBXSourcesBuildPhase section */ 139 | 140 | /* Begin XCBuildConfiguration section */ 141 | C82F045B2CCB3DD40012C07B /* Debug */ = { 142 | isa = XCBuildConfiguration; 143 | buildSettings = { 144 | ALWAYS_SEARCH_USER_PATHS = NO; 145 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 146 | CLANG_ANALYZER_NONNULL = YES; 147 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 148 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 149 | CLANG_ENABLE_MODULES = YES; 150 | CLANG_ENABLE_OBJC_ARC = YES; 151 | CLANG_ENABLE_OBJC_WEAK = YES; 152 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 153 | CLANG_WARN_BOOL_CONVERSION = YES; 154 | CLANG_WARN_COMMA = YES; 155 | CLANG_WARN_CONSTANT_CONVERSION = YES; 156 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 157 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 158 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 159 | CLANG_WARN_EMPTY_BODY = YES; 160 | CLANG_WARN_ENUM_CONVERSION = YES; 161 | CLANG_WARN_INFINITE_RECURSION = YES; 162 | CLANG_WARN_INT_CONVERSION = YES; 163 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 164 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 165 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 166 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 167 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 168 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 169 | CLANG_WARN_STRICT_PROTOTYPES = YES; 170 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 171 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 172 | CLANG_WARN_UNREACHABLE_CODE = YES; 173 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 174 | COPY_PHASE_STRIP = NO; 175 | DEAD_CODE_STRIPPING = YES; 176 | DEBUG_INFORMATION_FORMAT = dwarf; 177 | ENABLE_STRICT_OBJC_MSGSEND = YES; 178 | ENABLE_TESTABILITY = YES; 179 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 180 | GCC_C_LANGUAGE_STANDARD = gnu17; 181 | GCC_DYNAMIC_NO_PIC = NO; 182 | GCC_NO_COMMON_BLOCKS = YES; 183 | GCC_OPTIMIZATION_LEVEL = 0; 184 | GCC_PREPROCESSOR_DEFINITIONS = ( 185 | "DEBUG=1", 186 | "$(inherited)", 187 | ); 188 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 189 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 190 | GCC_WARN_UNDECLARED_SELECTOR = YES; 191 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 192 | GCC_WARN_UNUSED_FUNCTION = YES; 193 | GCC_WARN_UNUSED_VARIABLE = YES; 194 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 195 | MACOSX_DEPLOYMENT_TARGET = 15.0; 196 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 197 | MTL_FAST_MATH = YES; 198 | ONLY_ACTIVE_ARCH = YES; 199 | SDKROOT = macosx; 200 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 201 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 202 | }; 203 | name = Debug; 204 | }; 205 | C82F045C2CCB3DD40012C07B /* Release */ = { 206 | isa = XCBuildConfiguration; 207 | buildSettings = { 208 | ALWAYS_SEARCH_USER_PATHS = NO; 209 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 210 | CLANG_ANALYZER_NONNULL = YES; 211 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 212 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 213 | CLANG_ENABLE_MODULES = YES; 214 | CLANG_ENABLE_OBJC_ARC = YES; 215 | CLANG_ENABLE_OBJC_WEAK = YES; 216 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 217 | CLANG_WARN_BOOL_CONVERSION = YES; 218 | CLANG_WARN_COMMA = YES; 219 | CLANG_WARN_CONSTANT_CONVERSION = YES; 220 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 221 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 222 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 223 | CLANG_WARN_EMPTY_BODY = YES; 224 | CLANG_WARN_ENUM_CONVERSION = YES; 225 | CLANG_WARN_INFINITE_RECURSION = YES; 226 | CLANG_WARN_INT_CONVERSION = YES; 227 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 228 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 229 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 230 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 231 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 232 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 233 | CLANG_WARN_STRICT_PROTOTYPES = YES; 234 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 235 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 236 | CLANG_WARN_UNREACHABLE_CODE = YES; 237 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 238 | COPY_PHASE_STRIP = NO; 239 | DEAD_CODE_STRIPPING = YES; 240 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 241 | ENABLE_NS_ASSERTIONS = NO; 242 | ENABLE_STRICT_OBJC_MSGSEND = YES; 243 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 244 | GCC_C_LANGUAGE_STANDARD = gnu17; 245 | GCC_NO_COMMON_BLOCKS = YES; 246 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 247 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 248 | GCC_WARN_UNDECLARED_SELECTOR = YES; 249 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 250 | GCC_WARN_UNUSED_FUNCTION = YES; 251 | GCC_WARN_UNUSED_VARIABLE = YES; 252 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 253 | MACOSX_DEPLOYMENT_TARGET = 15.0; 254 | MTL_ENABLE_DEBUG_INFO = NO; 255 | MTL_FAST_MATH = YES; 256 | SDKROOT = macosx; 257 | SWIFT_COMPILATION_MODE = wholemodule; 258 | }; 259 | name = Release; 260 | }; 261 | C82F045E2CCB3DD40012C07B /* Debug */ = { 262 | isa = XCBuildConfiguration; 263 | buildSettings = { 264 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 265 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 266 | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 267 | CODE_SIGN_ENTITLEMENTS = Convierto/Convierto.entitlements; 268 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 269 | CODE_SIGN_STYLE = Automatic; 270 | COMBINE_HIDPI_IMAGES = YES; 271 | CURRENT_PROJECT_VERSION = 48; 272 | DEAD_CODE_STRIPPING = YES; 273 | DEVELOPMENT_ASSET_PATHS = "\"Convierto/Preview Content\""; 274 | DEVELOPMENT_TEAM = YYMLDY74QZ; 275 | ENABLE_HARDENED_RUNTIME = YES; 276 | ENABLE_PREVIEWS = YES; 277 | GENERATE_INFOPLIST_FILE = YES; 278 | INFOPLIST_FILE = Convierto/Info.plist; 279 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 280 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 281 | LD_RUNPATH_SEARCH_PATHS = ( 282 | "$(inherited)", 283 | "@executable_path/../Frameworks", 284 | ); 285 | MACOSX_DEPLOYMENT_TARGET = 14.0; 286 | MARKETING_VERSION = 1.0.1; 287 | PRODUCT_BUNDLE_IDENTIFIER = Minimal.Convierto; 288 | PRODUCT_NAME = "$(TARGET_NAME)"; 289 | SWIFT_EMIT_LOC_STRINGS = YES; 290 | SWIFT_VERSION = 5.0; 291 | }; 292 | name = Debug; 293 | }; 294 | C82F045F2CCB3DD40012C07B /* Release */ = { 295 | isa = XCBuildConfiguration; 296 | buildSettings = { 297 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 298 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 299 | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 300 | CODE_SIGN_ENTITLEMENTS = Convierto/Convierto.entitlements; 301 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; 302 | CODE_SIGN_STYLE = Automatic; 303 | COMBINE_HIDPI_IMAGES = YES; 304 | CURRENT_PROJECT_VERSION = 48; 305 | DEAD_CODE_STRIPPING = YES; 306 | DEVELOPMENT_ASSET_PATHS = "\"Convierto/Preview Content\""; 307 | DEVELOPMENT_TEAM = YYMLDY74QZ; 308 | ENABLE_HARDENED_RUNTIME = YES; 309 | ENABLE_PREVIEWS = YES; 310 | GENERATE_INFOPLIST_FILE = YES; 311 | INFOPLIST_FILE = Convierto/Info.plist; 312 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 313 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 314 | LD_RUNPATH_SEARCH_PATHS = ( 315 | "$(inherited)", 316 | "@executable_path/../Frameworks", 317 | ); 318 | MACOSX_DEPLOYMENT_TARGET = 14.0; 319 | MARKETING_VERSION = 1.0.1; 320 | PRODUCT_BUNDLE_IDENTIFIER = Minimal.Convierto; 321 | PRODUCT_NAME = "$(TARGET_NAME)"; 322 | SWIFT_EMIT_LOC_STRINGS = YES; 323 | SWIFT_VERSION = 5.0; 324 | }; 325 | name = Release; 326 | }; 327 | /* End XCBuildConfiguration section */ 328 | 329 | /* Begin XCConfigurationList section */ 330 | C82F04492CCB3DD20012C07B /* Build configuration list for PBXProject "Convierto" */ = { 331 | isa = XCConfigurationList; 332 | buildConfigurations = ( 333 | C82F045B2CCB3DD40012C07B /* Debug */, 334 | C82F045C2CCB3DD40012C07B /* Release */, 335 | ); 336 | defaultConfigurationIsVisible = 0; 337 | defaultConfigurationName = Release; 338 | }; 339 | C82F045D2CCB3DD40012C07B /* Build configuration list for PBXNativeTarget "Convierto" */ = { 340 | isa = XCConfigurationList; 341 | buildConfigurations = ( 342 | C82F045E2CCB3DD40012C07B /* Debug */, 343 | C82F045F2CCB3DD40012C07B /* Release */, 344 | ); 345 | defaultConfigurationIsVisible = 0; 346 | defaultConfigurationName = Release; 347 | }; 348 | /* End XCConfigurationList section */ 349 | }; 350 | rootObject = C82F04462CCB3DD20012C07B /* Project object */; 351 | } 352 | -------------------------------------------------------------------------------- /Convierto/App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | class AppDelegate: NSObject, NSApplicationDelegate { 4 | func applicationWillTerminate(_ notification: Notification) { 5 | CacheManager.shared.cleanupOldFiles() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Convierto/App/ConviertoApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | @main 5 | struct ConviertoApp: App { 6 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 7 | @AppStorage("isDarkMode") private var isDarkMode = false 8 | @StateObject private var updater = UpdateChecker() 9 | @State private var showingUpdateSheet = false 10 | 11 | var body: some Scene { 12 | WindowGroup { 13 | ContentView() 14 | .frame(minWidth: 400, minHeight: 500) 15 | .preferredColorScheme(isDarkMode ? .dark : .light) 16 | .background(WindowAccessor()) 17 | .sheet(isPresented: $showingUpdateSheet) { 18 | MenuBarView(updater: updater) 19 | } 20 | .onAppear { 21 | // Check for updates when app launches 22 | updater.checkForUpdates() 23 | 24 | // Set up observer for update availability 25 | updater.onUpdateAvailable = { 26 | showingUpdateSheet = true 27 | } 28 | } 29 | .coordinateSpace(name: "window") 30 | .clipShape(Rectangle()) 31 | } 32 | .windowStyle(.hiddenTitleBar) 33 | .commands { 34 | CommandGroup(after: .appInfo) { 35 | Button("Check for Updates...") { 36 | showingUpdateSheet = true 37 | updater.checkForUpdates() 38 | } 39 | .keyboardShortcut("U", modifiers: [.command]) 40 | 41 | if updater.updateAvailable { 42 | Button("Download Update") { 43 | if let url = updater.downloadURL { 44 | NSWorkspace.shared.open(url) 45 | } 46 | } 47 | } 48 | 49 | Divider() 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Convierto/Convierto.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.files.user-selected.read-write 10 | 11 | com.apple.security.network.client 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Convierto/Extensions/UTType+Extensions.swift: -------------------------------------------------------------------------------- 1 | import UniformTypeIdentifiers 2 | 3 | extension UTType { 4 | // Image formats - using optional initialization 5 | static let webP = UTType("public.webp") ?? .png 6 | static let raw = UTType("public.camera-raw-image") ?? .jpeg 7 | 8 | // Audio formats 9 | static let aac = UTType("public.aac-audio") ?? .mp3 10 | static let m4a = UTType("public.mpeg-4-audio") ?? .mp3 11 | static let aiff = UTType("public.aiff-audio") ?? .wav 12 | static let midi = UTType("public.midi-audio") ?? .mp3 13 | 14 | // Video formats 15 | static let avi = UTType("public.avi") ?? .mpeg4Movie 16 | static let m2v = UTType("public.mpeg-2-video") ?? .mpeg4Movie 17 | 18 | // Helper properties 19 | var isAudioFormat: Bool { 20 | self.conforms(to: .audio) 21 | } 22 | 23 | var isVideoFormat: Bool { 24 | self.conforms(to: .audiovisualContent) 25 | } 26 | 27 | var isImageFormat: Bool { 28 | self.conforms(to: .image) 29 | } 30 | 31 | var isPDFFormat: Bool { 32 | self == .pdf 33 | } 34 | 35 | // Helper for getting file icon 36 | var systemImageName: String { 37 | if isImageFormat { 38 | return "photo" 39 | } else if isVideoFormat { 40 | return "film" 41 | } else if isAudioFormat { 42 | return "waveform" 43 | } else if isPDFFormat { 44 | return "doc" 45 | } else { 46 | return "doc.fill" 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /Convierto/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | LSHandlerRank 9 | Default 10 | LSItemContentTypes 11 | 12 | public.image 13 | public.movie 14 | public.audio 15 | com.adobe.pdf 16 | public.content 17 | public.data 18 | 19 | NSDocumentClass 20 | FileDocument 21 | 22 | 23 | NSDesktopFolderUsageDescription 24 | We need access to process your files 25 | NSDownloadsFolderUsageDescription 26 | We need access to process your files 27 | NSDocumentsFolderUsageDescription 28 | We need access to process your files 29 | 30 | 31 | -------------------------------------------------------------------------------- /Convierto/Processor/AudioProcessorConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AudioProcessorConfig { 4 | let maxFrameCount: Int 5 | let defaultFPS: Int 6 | let conversionTimeout: TimeInterval 7 | var waveformSize: CGSize 8 | let defaultBufferSize: Int 9 | 10 | static let `default` = AudioProcessorConfig( 11 | maxFrameCount: 1800, 12 | defaultFPS: 30, 13 | conversionTimeout: 300, 14 | waveformSize: CGSize(width: 1920, height: 480), 15 | defaultBufferSize: 1024 * 1024 16 | ) 17 | 18 | func validate() throws { 19 | guard maxFrameCount > 0 else { 20 | throw ConversionError.invalidConfiguration("Frame count must be positive") 21 | } 22 | guard defaultFPS > 0 else { 23 | throw ConversionError.invalidConfiguration("FPS must be positive") 24 | } 25 | guard conversionTimeout > 0 else { 26 | throw ConversionError.invalidConfiguration("Timeout must be positive") 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Convierto/Processor/BaseConverter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AVFoundation 3 | import UniformTypeIdentifiers 4 | import os 5 | import CoreImage 6 | import AppKit 7 | 8 | protocol MediaConverting { 9 | func convert(_ url: URL, to format: UTType, metadata: ConversionMetadata, progress: Progress) async throws -> ProcessingResult 10 | func canConvert(from: UTType, to: UTType) -> Bool 11 | var settings: ConversionSettings { get } 12 | func validateConversion(from: UTType, to: UTType) throws -> ConversionStrategy 13 | } 14 | 15 | class BaseConverter: MediaConverting { 16 | let settings: ConversionSettings 17 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Convierto", category: "BaseConverter") 18 | 19 | // Memory requirements in bytes for different conversion types 20 | private struct MemoryRequirements { 21 | static let base: UInt64 = 100_000_000 // 100MB 22 | static let videoProcessing: UInt64 = 500_000_000 // 500MB 23 | static let imageToVideo: UInt64 = 250_000_000 // 250MB 24 | } 25 | 26 | required init(settings: ConversionSettings = ConversionSettings()) throws { 27 | self.settings = settings 28 | 29 | // Validate settings 30 | guard settings.videoBitRate > 0 else { 31 | throw ConversionError.invalidConfiguration("Video bitrate must be positive") 32 | } 33 | guard settings.audioBitRate > 0 else { 34 | throw ConversionError.invalidConfiguration("Audio bitrate must be positive") 35 | } 36 | 37 | logger.debug("Initialized BaseConverter with settings: \(String(describing: settings))") 38 | } 39 | 40 | /// Base implementation - must be overridden by subclasses 41 | func convert(_ url: URL, to format: UTType, metadata: ConversionMetadata, progress: Progress) async throws -> ProcessingResult { 42 | fatalError("convert(_:to:metadata:progress:) must be overridden by subclass") 43 | } 44 | 45 | /// Base implementation - must be overridden by subclasses 46 | func canConvert(from: UTType, to: UTType) -> Bool { 47 | fatalError("canConvert(from:to:) must be overridden by subclass") 48 | } 49 | 50 | func getAVFileType(for format: UTType) -> AVFileType { 51 | logger.debug("Determining AVFileType for format: \(format.identifier)") 52 | 53 | switch format { 54 | case _ where format == .mp3: 55 | return .mp3 56 | case _ where format == .wav || format.identifier == "com.microsoft.waveform-audio": 57 | return .wav 58 | case _ where format == .m4a || format.identifier == "public.mpeg-4-audio": 59 | return .m4a 60 | case _ where format == .aac: 61 | return .m4a // AAC is typically contained in M4A 62 | case _ where format.conforms(to: .audio): 63 | logger.debug("⚠️ Generic audio format, defaulting to M4A") 64 | return .m4a 65 | default: 66 | logger.debug("⚠️ Unknown format, defaulting to MP4: \(format.identifier)") 67 | return .mp4 68 | } 69 | } 70 | 71 | func createExportSession( 72 | for asset: AVAsset, 73 | outputFormat: UTType, 74 | isAudioOnly: Bool = false 75 | ) async throws -> AVAssetExportSession { 76 | let presetName = isAudioOnly ? AVAssetExportPresetAppleM4A : settings.videoQuality 77 | logger.debug("Creating export session with preset: \(presetName)") 78 | 79 | guard let exportSession = AVAssetExportSession(asset: asset, presetName: presetName) else { 80 | logger.error("Failed to create export session with preset: \(presetName)") 81 | throw ConversionError.conversionFailed(reason: "Failed to create export session with preset: \(presetName)") 82 | } 83 | 84 | // Validate supported file types 85 | guard exportSession.supportedFileTypes.contains(getAVFileType(for: outputFormat)) else { 86 | logger.error("Export session doesn't support output format: \(outputFormat.identifier)") 87 | throw ConversionError.unsupportedFormat(format: outputFormat) 88 | } 89 | 90 | return exportSession 91 | } 92 | 93 | func createAudioMix(for asset: AVAsset) async throws -> AVAudioMix? { 94 | guard let audioTrack = try? await asset.loadTracks(withMediaType: .audio).first else { 95 | logger.debug("No audio track found in asset") 96 | return nil 97 | } 98 | 99 | logger.debug("Creating audio mix for track: \(audioTrack)") 100 | 101 | let audioMix = AVMutableAudioMix() 102 | let parameters = AVMutableAudioMixInputParameters(track: audioTrack) 103 | 104 | // Configure audio parameters based on settings 105 | parameters.audioTimePitchAlgorithm = .spectral 106 | let duration = try await asset.load(.duration) 107 | 108 | parameters.setVolumeRamp( 109 | fromStartVolume: settings.audioStartVolume, 110 | toEndVolume: settings.audioEndVolume, 111 | timeRange: CMTimeRange(start: .zero, duration: duration) 112 | ) 113 | 114 | audioMix.inputParameters = [parameters] 115 | return audioMix 116 | } 117 | 118 | func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { 119 | try await withThrowingTaskGroup(of: T.self) { group in 120 | // Add the main operation 121 | group.addTask { 122 | try await operation() 123 | } 124 | 125 | // Add timeout task 126 | group.addTask { 127 | try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) 128 | throw ConversionError.timeout(duration: seconds) 129 | } 130 | 131 | // Get first completed result 132 | guard let result = try await group.next() else { 133 | throw ConversionError.timeout(duration: seconds) 134 | } 135 | 136 | // Cancel remaining tasks 137 | group.cancelAll() 138 | return result 139 | } 140 | } 141 | 142 | func validateConversion(from inputType: UTType, to outputType: UTType) throws -> ConversionStrategy { 143 | logger.debug("🔍 Validating conversion from \(inputType.identifier) to \(outputType.identifier)") 144 | 145 | // Ensure types are actually different 146 | if inputType == outputType { 147 | logger.debug("⚠️ Same input and output format detected") 148 | return .direct 149 | } 150 | 151 | // Validate basic compatibility 152 | guard canConvert(from: inputType, to: outputType) else { 153 | logger.error("❌ Incompatible formats detected") 154 | throw ConversionError.incompatibleFormats(from: inputType, to: outputType) 155 | } 156 | 157 | logger.debug("✅ Format validation successful") 158 | return .direct 159 | } 160 | 161 | func validateConversionCapabilities(from inputType: UTType, to outputType: UTType) throws { 162 | logger.debug("Validating conversion capabilities from \(inputType.identifier) to \(outputType.identifier)") 163 | 164 | // Check system resources 165 | let availableMemory = ProcessInfo.processInfo.physicalMemory 166 | let requiredMemory = estimateMemoryRequirement(for: inputType, to: outputType) 167 | 168 | logger.debug("Memory check - Required: \(String(describing: requiredMemory)), Available: \(String(describing: availableMemory))") 169 | 170 | if requiredMemory > availableMemory / 2 { 171 | logger.error("Insufficient memory for conversion") 172 | throw ConversionError.insufficientMemory( 173 | required: requiredMemory, 174 | available: availableMemory 175 | ) 176 | } 177 | 178 | // Validate format compatibility 179 | let strategy: ConversionStrategy 180 | do { 181 | strategy = try validateConversion(from: inputType, to: outputType) 182 | } catch { 183 | logger.error("Format compatibility validation failed: \(error.localizedDescription)") 184 | throw error 185 | } 186 | 187 | logger.debug("Conversion strategy determined: \(String(describing: strategy))") 188 | 189 | // Check if conversion is actually possible 190 | if !canActuallyConvert(from: inputType, to: outputType, strategy: strategy) { 191 | logger.error("Conversion not possible with current configuration") 192 | throw ConversionError.conversionNotPossible( 193 | reason: "Cannot convert from \(inputType.identifier) to \(outputType.identifier) using strategy \(String(describing: strategy))" 194 | ) 195 | } 196 | 197 | logger.debug("Conversion capabilities validation successful") 198 | } 199 | 200 | func canActuallyConvert(from inputType: UTType, to outputType: UTType, strategy: ConversionStrategy) -> Bool { 201 | logger.debug("Checking actual conversion possibility for strategy: \(String(describing: strategy))") 202 | 203 | // Verify system capabilities 204 | let hasRequiredFrameworks: Bool = verifyFrameworkAvailability(for: strategy) 205 | let hasRequiredPermissions: Bool = verifyPermissions(for: strategy) 206 | 207 | logger.debug("Frameworks available: \(String(describing: hasRequiredFrameworks))") 208 | logger.debug("Permissions verified: \(String(describing: hasRequiredPermissions))") 209 | 210 | return hasRequiredFrameworks && hasRequiredPermissions 211 | } 212 | 213 | private func estimateMemoryRequirement(for inputType: UTType, to outputType: UTType) -> UInt64 { 214 | // Base memory requirement 215 | var requirement: UInt64 = 100_000_000 // 100MB base 216 | 217 | // Add memory based on conversion type 218 | if inputType.conforms(to: .audiovisualContent) || outputType.conforms(to: .audiovisualContent) { 219 | requirement += 500_000_000 // +500MB for video processing 220 | } 221 | 222 | if inputType.conforms(to: .image) && outputType.conforms(to: .audiovisualContent) { 223 | requirement += 250_000_000 // +250MB for image-to-video 224 | } 225 | 226 | return requirement 227 | } 228 | 229 | private func verifyFrameworkAvailability(for strategy: ConversionStrategy) -> Bool { 230 | switch strategy { 231 | case .createVideo: 232 | if #available(macOS 13.0, *) { 233 | let session = AVAssetExportSession(asset: AVAsset(), presetName: AVAssetExportPresetHighestQuality) 234 | return session?.supportedFileTypes.contains(.mp4) ?? false 235 | } 236 | return AVAssetExportSession.allExportPresets().contains(AVAssetExportPresetHighestQuality) 237 | 238 | case .visualize: 239 | #if canImport(CoreImage) 240 | return CIContext(options: [CIContextOption.useSoftwareRenderer: false]) != nil 241 | #else 242 | return false 243 | #endif 244 | 245 | case .combine: 246 | return NSGraphicsContext.current != nil 247 | 248 | case .extractAudio: 249 | if #available(macOS 13.0, *) { 250 | let session = AVAssetExportSession(asset: AVAsset(), presetName: AVAssetExportPresetAppleM4A) 251 | return session?.supportedFileTypes.contains(.m4a) ?? false 252 | } else { 253 | return AVAssetExportSession.allExportPresets().contains(AVAssetExportPresetAppleM4A) 254 | } 255 | default: 256 | return true 257 | } 258 | } 259 | 260 | private func verifyPermissions(for strategy: ConversionStrategy) -> Bool { 261 | switch strategy { 262 | case .createVideo, .extractFrame, .visualize: 263 | return true // No special permissions needed for media processing 264 | case .extractAudio: 265 | return true // Audio processing doesn't require special permissions 266 | case .combine: 267 | return true // Document processing doesn't require special permissions 268 | case .direct: 269 | return true // Basic conversion doesn't require special permissions 270 | } 271 | } 272 | 273 | func validateContext(_ context: CIContext?) throws { 274 | guard context != nil else { 275 | throw ConversionError.conversionFailed(reason: "Invalid graphics context") 276 | } 277 | } 278 | 279 | func validateType(_ type: Any.Type?) throws { 280 | guard let _ = type else { 281 | throw ConversionError.conversionFailed(reason: "Invalid type") 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /Convierto/Processor/CacheManager.swift: -------------------------------------------------------------------------------- 1 | // CacheManager.swift 2 | import Foundation 3 | import AppKit 4 | 5 | class CacheManager { 6 | static let shared = CacheManager() 7 | 8 | private let cacheDirectory: URL 9 | private let maxCacheAge: TimeInterval = 24 * 60 * 60 // 24 hours 10 | 11 | private var activeFiles: Set = [] 12 | private let activeFilesQueue = DispatchQueue(label: "com.convierto.cachemanager.activefiles") 13 | 14 | private init() { 15 | let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! 16 | cacheDirectory = cacheDir.appendingPathComponent("com.convierto.filecache", isDirectory: true) 17 | 18 | do { 19 | try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) 20 | } catch { 21 | print("Failed to create cache directory: \(error)") 22 | } 23 | 24 | // Setup automatic cleanup 25 | setupAutomaticCleanup() 26 | } 27 | 28 | func createTemporaryURL(for filename: String) throws -> URL { 29 | // Clean the filename 30 | let cleanFilename = filename.components(separatedBy: "_").last ?? filename 31 | let tempFilename = "\(UUID().uuidString).\(cleanFilename)" 32 | let fileURL = cacheDirectory.appendingPathComponent(tempFilename) 33 | 34 | // Check if file already exists and remove it 35 | if FileManager.default.fileExists(atPath: fileURL.path) { 36 | try FileManager.default.removeItem(at: fileURL) 37 | } 38 | 39 | return fileURL 40 | } 41 | 42 | func cleanupOldFiles() { 43 | // Only clean files that aren't currently active 44 | let currentActiveFiles = activeFilesQueue.sync { activeFiles } 45 | 46 | let cutoffDate = Date().addingTimeInterval(-3600) // 1 hour 47 | let fileManager = FileManager.default 48 | 49 | guard let enumerator = fileManager.enumerator( 50 | at: cacheDirectory, 51 | includingPropertiesForKeys: [.creationDateKey, .isDirectoryKey] 52 | ) else { return } 53 | 54 | for case let fileURL as URL in enumerator { 55 | guard !currentActiveFiles.contains(fileURL) else { continue } 56 | do { 57 | let resourceValues = try fileURL.resourceValues(forKeys: Set([.creationDateKey, .isDirectoryKey])) 58 | if let creationDate = resourceValues.creationDate, 59 | let isDirectory = resourceValues.isDirectory, 60 | !isDirectory && creationDate < cutoffDate { 61 | try fileManager.removeItem(at: fileURL) 62 | } 63 | } catch { 64 | print("Error cleaning up file at \(fileURL): \(error)") 65 | } 66 | } 67 | } 68 | 69 | private func setupAutomaticCleanup() { 70 | // Clean up on app launch 71 | cleanupOldFiles() 72 | 73 | // Register for app termination notification 74 | NotificationCenter.default.addObserver( 75 | forName: NSApplication.willTerminateNotification, 76 | object: nil, 77 | queue: .main 78 | ) { [weak self] _ in 79 | self?.cleanupOldFiles() 80 | } 81 | } 82 | 83 | func cleanupTemporaryFiles() throws { 84 | let fileManager = FileManager.default 85 | let resourceKeys: [URLResourceKey] = [.creationDateKey, .isDirectoryKey] 86 | 87 | guard let enumerator = fileManager.enumerator( 88 | at: cacheDirectory, 89 | includingPropertiesForKeys: resourceKeys, 90 | options: .skipsHiddenFiles 91 | ) else { return } 92 | 93 | let cutoffDate = Date().addingTimeInterval(-maxCacheAge) 94 | 95 | while let fileURL = enumerator.nextObject() as? URL { 96 | do { 97 | let resourceValues = try fileURL.resourceValues(forKeys: Set(resourceKeys)) 98 | if let creationDate = resourceValues.creationDate, 99 | let isDirectory = resourceValues.isDirectory, 100 | !isDirectory && creationDate < cutoffDate { 101 | try fileManager.removeItem(at: fileURL) 102 | } 103 | } catch { 104 | print("Error cleaning up file at \(fileURL): \(error)") 105 | } 106 | } 107 | } 108 | 109 | func createTemporaryFile(withExtension ext: String) async throws -> URL { 110 | let filename = "\(UUID().uuidString).\(ext)" 111 | let fileURL = cacheDirectory.appendingPathComponent(filename) 112 | 113 | // Ensure the directory exists 114 | try FileManager.default.createDirectory( 115 | at: cacheDirectory, 116 | withIntermediateDirectories: true 117 | ) 118 | 119 | return fileURL 120 | } 121 | 122 | func trackActiveFile(_ url: URL) { 123 | _ = activeFilesQueue.sync { 124 | activeFiles.insert(url) 125 | } 126 | } 127 | 128 | func untrackActiveFile(_ url: URL) { 129 | _ = activeFilesQueue.sync { 130 | activeFiles.remove(url) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Convierto/Processor/ConversionCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | import os.log 4 | import AppKit 5 | import Combine 6 | 7 | class ConversionCoordinator: NSObject { 8 | private let queue = OperationQueue() 9 | private let maxRetries = 3 10 | private let resourceManager = ResourceManager.shared 11 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Convierto", category: "ConversionCoordinator") 12 | private var cancellables = Set() 13 | 14 | // Configuration 15 | private let settings: ConversionSettings 16 | 17 | private var activeConversions = Set() 18 | private let activeConversionsQueue = DispatchQueue(label: "com.convierto.activeConversions") 19 | 20 | // Add a property to track active processing 21 | private var isProcessing: Bool = false 22 | private let processingQueue = DispatchQueue(label: "com.convierto.processing") 23 | 24 | init(settings: ConversionSettings = ConversionSettings()) { 25 | self.settings = settings 26 | super.init() 27 | setupQueue() 28 | setupQueueMonitoring() 29 | ProcessorFactory.setupShared(coordinator: self, settings: settings) 30 | } 31 | 32 | private func setupQueue() { 33 | queue.maxConcurrentOperationCount = 1 // Serial queue for predictable resource usage 34 | queue.qualityOfService = .userInitiated 35 | } 36 | 37 | private func setupQueueMonitoring() { 38 | // Use Combine to monitor queue operations 39 | queue.publisher(for: \.operationCount) 40 | .filter { $0 == 0 } 41 | .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) 42 | .sink { [weak self] _ in 43 | self?.handleQueueEmpty() 44 | } 45 | .store(in: &cancellables) 46 | } 47 | 48 | private func handleQueueEmpty() { 49 | processingQueue.sync { 50 | // Only perform cleanup if we're not processing and have no active conversions 51 | if !isProcessing && activeConversions.isEmpty { 52 | // Add a longer delay to ensure all processes are complete 53 | Task { 54 | try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 second delay 55 | // Double check that we're still not processing 56 | if !self.isProcessing && self.activeConversions.isEmpty { 57 | await performCleanup() 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | func trackConversion(_ id: UUID) async { 65 | logger.debug("📝 Tracking conversion: \(id.uuidString)") 66 | await withCheckedContinuation { continuation in 67 | activeConversionsQueue.async { 68 | self.activeConversions.insert(id) 69 | continuation.resume() 70 | } 71 | } 72 | } 73 | 74 | func untrackConversion(_ id: UUID) async { 75 | logger.debug("🗑 Untracking conversion: \(id.uuidString)") 76 | await withCheckedContinuation { continuation in 77 | activeConversionsQueue.async { 78 | self.activeConversions.remove(id) 79 | // Only schedule cleanup if this was the last conversion 80 | if self.activeConversions.isEmpty { 81 | Task { 82 | // Increase delay before cleanup 83 | try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 second delay 84 | // Double check active conversions and processing state 85 | if self.activeConversions.isEmpty && !self.isProcessing { 86 | await self.performCleanup() 87 | } 88 | } 89 | } 90 | continuation.resume() 91 | } 92 | } 93 | } 94 | 95 | private func performCleanup() async { 96 | // Check one final time before cleanup 97 | guard await shouldPerformCleanup() else { 98 | logger.debug("🚫 Skipping cleanup - active processing detected") 99 | return 100 | } 101 | 102 | logger.debug("🧹 Starting cleanup process") 103 | try? await Task.sleep(nanoseconds: 100_000) 104 | await resourceManager.cleanup() 105 | logger.debug("✅ Cleanup completed") 106 | } 107 | 108 | private func shouldPerformCleanup() async -> Bool { 109 | await withCheckedContinuation { continuation in 110 | processingQueue.sync { 111 | continuation.resume(returning: !isProcessing && activeConversions.isEmpty) 112 | } 113 | } 114 | } 115 | 116 | private func performConversion( 117 | url: URL, 118 | to outputFormat: UTType, 119 | metadata: ConversionMetadata, 120 | progress: Progress 121 | ) async throws -> ProcessingResult { 122 | processingQueue.sync { isProcessing = true } 123 | defer { 124 | processingQueue.sync { isProcessing = false } 125 | } 126 | 127 | let conversionId = UUID() 128 | logger.debug("🔄 Starting conversion process: \(conversionId.uuidString)") 129 | 130 | // Get input type before conversion 131 | let resourceValues = try await url.resourceValues(forKeys: [.contentTypeKey]) 132 | guard let inputType = resourceValues.contentType else { 133 | throw ConversionError.invalidInput 134 | } 135 | 136 | return try await withThrowingTaskGroup(of: ProcessingResult.self) { group in 137 | group.addTask { 138 | // Track conversion 139 | await self.trackConversion(conversionId) 140 | 141 | defer { 142 | Task { 143 | await self.untrackConversion(conversionId) 144 | } 145 | } 146 | 147 | let converter = try await self.createConverter(for: inputType, targetFormat: outputFormat) 148 | let result = try await converter.convert(url, to: outputFormat, metadata: metadata, progress: progress) 149 | 150 | // Add delay before cleanup 151 | try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second delay 152 | 153 | return result 154 | } 155 | 156 | let result = try await group.next() 157 | if let result = result { 158 | return result 159 | } else { 160 | throw ConversionError.conversionFailed(reason: "Conversion failed to complete") 161 | } 162 | } 163 | } 164 | 165 | private func createConverter( 166 | for inputType: UTType, 167 | targetFormat: UTType 168 | ) async throws -> MediaConverting { 169 | // Select appropriate converter based on input and output types 170 | if inputType.conforms(to: .image) && targetFormat.conforms(to: .image) { 171 | return try ImageProcessor(settings: settings) 172 | } else if inputType.conforms(to: .audiovisualContent) || targetFormat.conforms(to: .audiovisualContent) { 173 | return try VideoProcessor(settings: settings) 174 | } else if inputType.conforms(to: .audio) || targetFormat.conforms(to: .audio) { 175 | return try AudioProcessor(settings: settings) 176 | } else if inputType.conforms(to: .pdf) || targetFormat.conforms(to: .pdf) { 177 | return try DocumentProcessor(settings: settings) 178 | } 179 | 180 | throw ConversionError.unsupportedConversion("No converter available for \(inputType.identifier) to \(targetFormat.identifier)") 181 | } 182 | 183 | func convert( 184 | url: URL, 185 | to outputFormat: UTType, 186 | metadata: ConversionMetadata, 187 | progress: Progress 188 | ) async throws -> ProcessingResult { 189 | let contextId = UUID().uuidString 190 | logger.debug("🎬 Starting conversion process (Context: \(contextId))") 191 | 192 | // Track conversion context 193 | resourceManager.trackContext(contextId) 194 | 195 | defer { 196 | logger.debug("🔄 Cleaning up conversion context: \(contextId)") 197 | resourceManager.releaseContext(contextId) 198 | } 199 | 200 | // Validate input before proceeding 201 | try await validateInput(url: url, targetFormat: outputFormat) 202 | 203 | return try await withRetries( 204 | maxRetries: maxRetries, 205 | operation: { [weak self] in 206 | guard let self = self else { 207 | throw ConversionError.conversionFailed(reason: "Coordinator was deallocated") 208 | } 209 | 210 | return try await self.performConversion( 211 | url: url, 212 | to: outputFormat, 213 | metadata: metadata, 214 | progress: progress 215 | ) 216 | }, 217 | retryDelay: 1.0 218 | ) 219 | } 220 | 221 | private func validateInput(url: URL, targetFormat: UTType) async throws { 222 | logger.debug("🔍 Validating input parameters") 223 | 224 | // Check if file exists and is readable 225 | guard FileManager.default.isReadableFile(atPath: url.path) else { 226 | throw ConversionError.fileAccessDenied(path: url.path) 227 | } 228 | 229 | // Validate file size 230 | let attributes = try FileManager.default.attributesOfItem(atPath: url.path) 231 | let fileSize = attributes[.size] as? UInt64 ?? 0 232 | 233 | // Check available memory 234 | let available = await ResourcePool.shared.getAvailableMemory() 235 | guard available >= fileSize * 2 else { // Require 2x file size as buffer 236 | throw ConversionError.insufficientMemory( 237 | required: fileSize * 2, 238 | available: available 239 | ) 240 | } 241 | 242 | logger.debug("✅ Input validation successful") 243 | } 244 | 245 | private func withRetries( 246 | maxRetries: Int, 247 | operation: @escaping () async throws -> T, 248 | retryDelay: TimeInterval 249 | ) async throws -> T { 250 | var lastError: Error? 251 | 252 | for attempt in 0.. 0 { 255 | let delay = calculateRetryDelay(attempt: attempt, baseDelay: retryDelay) 256 | logger.debug("⏳ Retry attempt \(attempt + 1) after \(delay) seconds") 257 | try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) 258 | } 259 | 260 | return try await operation() 261 | } catch let error as ConversionError { 262 | lastError = error 263 | logger.error("❌ Attempt \(attempt + 1) failed: \(error.localizedDescription)") 264 | 265 | // Don't retry certain errors 266 | if case .invalidInput = error { throw error } 267 | if case .insufficientMemory = error { throw error } 268 | } catch { 269 | lastError = error 270 | logger.error("❌ Unexpected error in attempt \(attempt + 1): \(error.localizedDescription)") 271 | } 272 | } 273 | 274 | logger.error("❌ All retry attempts failed") 275 | throw lastError ?? ConversionError.conversionFailed(reason: "Max retries exceeded") 276 | } 277 | 278 | private func calculateRetryDelay(attempt: Int, baseDelay: TimeInterval) -> TimeInterval { 279 | let maxDelay: TimeInterval = 30.0 // Maximum delay of 30 seconds 280 | let delay = baseDelay * pow(2.0, Double(attempt)) 281 | return min(delay, maxDelay) 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /Convierto/Processor/ConversionError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | 4 | enum ConversionError: LocalizedError { 5 | case invalidInput 6 | case conversionFailed(reason: String) 7 | case exportFailed(reason: String) 8 | case incompatibleFormats(from: UTType, to: UTType, reason: String? = nil) 9 | case unsupportedFormat(format: UTType) 10 | case insufficientMemory(required: UInt64, available: UInt64) 11 | case insufficientDiskSpace(required: UInt64, available: UInt64) 12 | case timeout(duration: TimeInterval) 13 | case documentProcessingFailed(reason: String) 14 | case documentUnsupported(format: UTType) 15 | case fileAccessDenied(path: String) 16 | case sandboxViolation(reason: String) 17 | case contextError(reason: String) 18 | case resourceExhausted(resource: String) 19 | case conversionNotPossible(reason: String) 20 | case invalidInputType 21 | case cancelled 22 | case featureNotImplemented(feature: String) 23 | case invalidConfiguration(String) 24 | case unsupportedConversion(String) 25 | 26 | var errorDescription: String? { 27 | switch self { 28 | case .invalidInput: 29 | return "Invalid input file" 30 | case .conversionFailed(let reason): 31 | return "Conversion failed: \(reason)" 32 | case .exportFailed(let reason): 33 | return "Export failed: \(reason)" 34 | case .incompatibleFormats(let from, let to, let reason): 35 | return "Cannot convert from \(from.localizedDescription ?? "unknown") to \(to.localizedDescription ?? "unknown"). Reason: \(reason ?? "unknown")" 36 | case .unsupportedFormat(let format): 37 | return "Unsupported format: \(format.localizedDescription ?? "unknown")" 38 | case .insufficientMemory(let required, let available): 39 | return "Insufficient memory: Required \(ByteCountFormatter.string(fromByteCount: Int64(required), countStyle: .binary)), Available \(ByteCountFormatter.string(fromByteCount: Int64(available), countStyle: .binary))" 40 | case .insufficientDiskSpace(let required, let available): 41 | return "Insufficient disk space: Required \(ByteCountFormatter.string(fromByteCount: Int64(required), countStyle: .binary)), Available \(ByteCountFormatter.string(fromByteCount: Int64(available), countStyle: .binary))" 42 | case .timeout(let duration): 43 | return "Operation timed out after \(String(format: "%.1f", duration)) seconds" 44 | case .documentProcessingFailed(let reason): 45 | return "Document processing failed: \(reason)" 46 | case .documentUnsupported(let format): 47 | return "Document format not supported: \(format.localizedDescription ?? "unknown")" 48 | case .fileAccessDenied(let path): 49 | return "Access denied to file: \(path)" 50 | case .sandboxViolation(let reason): 51 | return "Sandbox violation: \(reason)" 52 | case .contextError(let reason): 53 | return "Graphics context error: \(reason)" 54 | case .resourceExhausted(let resource): 55 | return "Resource exhausted: \(resource)" 56 | case .conversionNotPossible(let reason): 57 | return "Conversion not possible: \(reason)" 58 | case .invalidInputType: 59 | return "InvalidInputType" 60 | case .cancelled: 61 | return "Operation was cancelled" 62 | case .featureNotImplemented(let feature): 63 | return "Feature not implemented: \(feature)" 64 | case .invalidConfiguration(let reason): 65 | return "Invalid configuration: \(reason)" 66 | case .unsupportedConversion(let reason): 67 | return "Unsupported conversion: \(reason)" 68 | } 69 | } 70 | 71 | var recoverySuggestion: String? { 72 | switch self { 73 | case .insufficientMemory: 74 | return "Try closing other applications or converting smaller files" 75 | case .insufficientDiskSpace: 76 | return "Free up disk space and try again" 77 | case .timeout: 78 | return "Try converting a smaller file or simplifying the conversion" 79 | case .conversionNotPossible: 80 | return "Try converting to a different format or check if the input file is valid" 81 | default: 82 | return nil 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Convierto/Processor/ConversionStrategy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ConversionStrategy { 4 | case direct // Standard conversion 5 | case extractFrame // Video to Image 6 | case createVideo // Image to Video 7 | case visualize // Audio to Video/Image 8 | case extractAudio // Video to Audio 9 | case combine // Multiple files to one 10 | 11 | var requiresBuffering: Bool { 12 | switch self { 13 | case .direct: return false 14 | case .extractFrame: return true 15 | case .createVideo: return true 16 | case .visualize: return true 17 | case .extractAudio: return false 18 | case .combine: return true 19 | } 20 | } 21 | 22 | var estimatedMemoryUsage: Int64 { 23 | switch self { 24 | case .direct: return 100_000_000 // 100MB 25 | case .extractFrame: return 500_000_000 // 500MB 26 | case .createVideo: return 1_000_000_000 // 1GB 27 | case .visualize: return 750_000_000 // 750MB 28 | case .extractAudio: return 250_000_000 // 250MB 29 | case .combine: return 1_500_000_000 // 1.5GB 30 | } 31 | } 32 | 33 | var canFallback: Bool { 34 | switch self { 35 | case .direct: return false 36 | case .extractFrame: return true 37 | case .createVideo: return true 38 | case .visualize: return true 39 | case .extractAudio: return false 40 | case .combine: return true 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /Convierto/Processor/DocumentProcessor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PDFKit 3 | import UniformTypeIdentifiers 4 | import AppKit 5 | import Vision 6 | import CoreGraphics 7 | import os.log 8 | 9 | class DocumentProcessor: BaseConverter { 10 | private let imageProcessor: ImageProcessor 11 | private let resourcePool: ResourcePool 12 | private let maxPageBufferSize: UInt64 = 100 * 1024 * 1024 // 100MB per page 13 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Convierto", category: "DocumentProcessor") 14 | 15 | required init(settings: ConversionSettings = ConversionSettings()) throws { 16 | self.resourcePool = ResourcePool.shared 17 | self.imageProcessor = try ImageProcessor(settings: settings) 18 | try super.init(settings: settings) 19 | } 20 | 21 | override func canConvert(from: UTType, to: UTType) -> Bool { 22 | switch (from, to) { 23 | case (.pdf, let t) where t.conforms(to: .image): 24 | return true 25 | case (let f, .pdf) where f.conforms(to: .image): 26 | return true 27 | case (.pdf, let t) where t.conforms(to: .audiovisualContent): 28 | return true 29 | default: 30 | return false 31 | } 32 | } 33 | 34 | override func convert(_ url: URL, to format: UTType, metadata: ConversionMetadata, progress: Progress) async throws -> ProcessingResult { 35 | logger.debug("📄 Starting document conversion process") 36 | logger.debug("📂 Input file: \(url.path)") 37 | logger.debug("🎯 Target format: \(format.identifier)") 38 | 39 | let taskId = UUID() 40 | logger.debug("🔑 Task ID: \(taskId.uuidString)") 41 | 42 | await resourcePool.beginTask(id: taskId, type: .document) 43 | defer { Task { await resourcePool.endTask(id: taskId) } } 44 | 45 | do { 46 | let inputType = try await determineInputType(url) 47 | logger.debug("📋 Input type determined: \(inputType.identifier)") 48 | 49 | let strategy = try validateConversion(from: inputType, to: format) 50 | logger.debug("⚙️ Conversion strategy: \(String(describing: strategy))") 51 | 52 | try await resourcePool.checkResourceAvailability(taskId: taskId, type: .document) 53 | logger.debug("✅ Resource availability confirmed") 54 | 55 | switch strategy { 56 | case .extractFrame: 57 | logger.debug("🖼️ Converting PDF to image") 58 | return try await convertPDFToImage(url, outputFormat: format, metadata: metadata, progress: progress) 59 | case .combine: 60 | logger.debug("📑 Converting image to PDF") 61 | return try await convertImageToPDF(url, metadata: metadata, progress: progress) 62 | case .createVideo: 63 | logger.debug("🎬 Converting PDF to video") 64 | return try await convertPDFToVideo(url, outputFormat: format, metadata: metadata, progress: progress) 65 | default: 66 | logger.error("❌ Invalid conversion strategy") 67 | throw ConversionError.conversionFailed(reason: "Invalid conversion strategy") 68 | } 69 | } catch { 70 | logger.error("❌ Document conversion failed: \(error.localizedDescription)") 71 | throw error 72 | } 73 | } 74 | 75 | private func determineInputType(_ url: URL) async throws -> UTType { 76 | let resourceValues = try url.resourceValues(forKeys: [.contentTypeKey]) 77 | guard let contentType = resourceValues.contentType else { 78 | throw ConversionError.invalidInputType 79 | } 80 | return contentType 81 | } 82 | 83 | private func convertPDFToImage(_ url: URL, outputFormat: UTType, metadata: ConversionMetadata, progress: Progress) async throws -> ProcessingResult { 84 | guard let document = PDFDocument(url: url) else { 85 | throw ConversionError.documentProcessingFailed(reason: "Could not open PDF document") 86 | } 87 | 88 | let pageCount = document.pageCount 89 | guard pageCount > 0 else { 90 | throw ConversionError.invalidInput 91 | } 92 | 93 | try await resourcePool.checkMemoryAvailability(required: maxPageBufferSize * UInt64(pageCount)) 94 | 95 | if pageCount == 1 { 96 | guard let page = document.page(at: 0) else { 97 | throw ConversionError.documentProcessingFailed(reason: "Invalid PDF page") 98 | } 99 | let image = renderPDFPage(page) 100 | let outputURL = try CacheManager.shared.createTemporaryURL(for: outputFormat.preferredFilenameExtension ?? "jpg") 101 | try await imageProcessor.saveImage(image, format: outputFormat, to: outputURL, metadata: metadata) 102 | progress.completedUnitCount = 100 103 | 104 | return ProcessingResult( 105 | outputURL: outputURL, 106 | originalFileName: metadata.originalFileName ?? "document", 107 | suggestedFileName: "converted_page." + (outputFormat.preferredFilenameExtension ?? "jpg"), 108 | fileType: outputFormat, 109 | metadata: nil 110 | ) 111 | } else { 112 | return try await convertMultiplePages(document, format: outputFormat, metadata: metadata, progress: progress) 113 | } 114 | } 115 | 116 | private func convertSinglePage( 117 | _ page: PDFPage?, 118 | format: UTType, 119 | metadata: ConversionMetadata, 120 | progress: Progress 121 | ) async throws -> ProcessingResult { 122 | guard let page = page else { 123 | throw ConversionError.documentProcessingFailed(reason: "Invalid PDF page") 124 | } 125 | 126 | let outputURL = try CacheManager.shared.createTemporaryURL(for: format.preferredFilenameExtension ?? "jpg") 127 | let image = renderPDFPage(page) 128 | 129 | try await imageProcessor.saveImage(image, format: format, to: outputURL, metadata: metadata) 130 | progress.completedUnitCount = 100 131 | 132 | return ProcessingResult( 133 | outputURL: outputURL, 134 | originalFileName: metadata.originalFileName ?? "document", 135 | suggestedFileName: "converted_page." + (format.preferredFilenameExtension ?? "jpg"), 136 | fileType: format, 137 | metadata: nil 138 | ) 139 | } 140 | 141 | private func convertMultiplePages( 142 | _ document: PDFDocument, 143 | format: UTType, 144 | metadata: ConversionMetadata, 145 | progress: Progress 146 | ) async throws -> ProcessingResult { 147 | let fileManager = FileManager.default 148 | let tempDir = fileManager.temporaryDirectory 149 | let outputDir = tempDir.appendingPathComponent(UUID().uuidString, isDirectory: true) 150 | try fileManager.createDirectory(at: outputDir, withIntermediateDirectories: true) 151 | 152 | progress.totalUnitCount = Int64(document.pageCount) 153 | var convertedFiles: [URL] = [] 154 | 155 | for pageIndex in 0.. NSImage { 177 | autoreleasepool { 178 | let pageRect = page.bounds(for: .mediaBox) 179 | let renderer = NSImage(size: pageRect.size) 180 | 181 | renderer.lockFocus() 182 | if let context = NSGraphicsContext.current { 183 | context.imageInterpolation = .high 184 | context.shouldAntialias = true 185 | page.draw(with: .mediaBox, to: context.cgContext) 186 | } 187 | renderer.unlockFocus() 188 | 189 | return renderer 190 | } 191 | } 192 | 193 | private func convertPDFToVideo(_ url: URL, outputFormat: UTType, metadata: ConversionMetadata, progress: Progress) async throws -> ProcessingResult { 194 | // For now, throw a more descriptive error 195 | throw ConversionError.featureNotImplemented(feature: "PDF to video conversion") 196 | } 197 | 198 | private func convertImageToPDF(_ url: URL, metadata: ConversionMetadata, progress: Progress) async throws -> ProcessingResult { 199 | let outputURL = try CacheManager.shared.createTemporaryURL(for: "pdf") 200 | 201 | guard let image = NSImage(contentsOf: url) else { 202 | throw ConversionError.documentProcessingFailed(reason: "Could not load image") 203 | } 204 | 205 | let pdfData = NSMutableData() 206 | var mediaBox = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) 207 | 208 | guard let context = CGContext(consumer: CGDataConsumer(data: pdfData)!, 209 | mediaBox: &mediaBox, 210 | nil) else { 211 | throw ConversionError.conversionFailed(reason: "Could not create PDF context") 212 | } 213 | 214 | guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 215 | throw ConversionError.conversionFailed(reason: "Could not create CGImage") 216 | } 217 | 218 | context.beginPage(mediaBox: &mediaBox) 219 | context.draw(cgImage, in: mediaBox) 220 | context.endPage() 221 | context.closePDF() 222 | 223 | try pdfData.write(to: outputURL, options: .atomic) 224 | progress.completedUnitCount = 100 225 | 226 | return ProcessingResult( 227 | outputURL: outputURL, 228 | originalFileName: metadata.originalFileName ?? "image", 229 | suggestedFileName: "converted_document.pdf", 230 | fileType: .pdf, 231 | metadata: nil 232 | ) 233 | } 234 | 235 | override func validateConversion(from inputType: UTType, to outputType: UTType) throws -> ConversionStrategy { 236 | logger.debug("🔍 Validating conversion from \(inputType.identifier) to \(outputType.identifier)") 237 | 238 | switch (inputType, outputType) { 239 | case (.pdf, let t) where t.conforms(to: .image): 240 | return .extractFrame 241 | case (let f, .pdf) where f.conforms(to: .image): 242 | return .combine 243 | case (.pdf, let t) where t.conforms(to: .audiovisualContent): 244 | return .createVideo 245 | default: 246 | logger.error("❌ Unsupported conversion combination") 247 | throw ConversionError.incompatibleFormats( 248 | from: inputType, 249 | to: outputType, 250 | reason: "Unsupported conversion combination" 251 | ) 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /Convierto/Processor/ProcessorFactory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | import OSLog 4 | 5 | class ProcessorFactory { 6 | private static var _shared: ProcessorFactory? 7 | static var shared: ProcessorFactory { 8 | guard let existing = _shared else { 9 | fatalError("ProcessorFactory.shared accessed before initialization. Call setupShared(coordinator:) first") 10 | } 11 | return existing 12 | } 13 | 14 | private var processors: [String: BaseConverter] = [:] 15 | private let settings: ConversionSettings 16 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Convierto", category: "ProcessorFactory") 17 | weak var coordinator: ConversionCoordinator? 18 | 19 | static func setupShared(coordinator: ConversionCoordinator, settings: ConversionSettings = ConversionSettings()) { 20 | _shared = ProcessorFactory(settings: settings, coordinator: coordinator) 21 | } 22 | 23 | init(settings: ConversionSettings = ConversionSettings(), coordinator: ConversionCoordinator) { 24 | self.settings = settings 25 | self.coordinator = coordinator 26 | } 27 | 28 | func processor(for type: UTType) throws -> BaseConverter { 29 | let key = type.identifier 30 | 31 | if let existing = processors[key] { 32 | return existing 33 | } 34 | 35 | let processor: BaseConverter 36 | 37 | do { 38 | if type.conforms(to: .image) { 39 | processor = try ImageProcessor(settings: settings) 40 | } else if type.conforms(to: .audiovisualContent) { 41 | processor = try VideoProcessor(settings: settings) 42 | } else if type.conforms(to: .audio) { 43 | processor = try AudioProcessor(settings: settings) 44 | } else if type == .pdf { 45 | processor = try DocumentProcessor(settings: settings) 46 | } else { 47 | processor = try BaseConverter(settings: settings) 48 | } 49 | 50 | processors[key] = processor 51 | logger.debug("Created processor for type: \(type.identifier)") 52 | return processor 53 | } catch { 54 | logger.error("Failed to create processor: \(error.localizedDescription)") 55 | throw ConversionError.invalidConfiguration("Failed to create processor: \(error.localizedDescription)") 56 | } 57 | } 58 | 59 | func releaseProcessor(for type: UTType) { 60 | processors.removeValue(forKey: type.identifier) 61 | } 62 | 63 | nonisolated func cleanup() { 64 | Task { @MainActor in 65 | processors.removeAll() 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Convierto/Processor/VideoProcessor.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import UniformTypeIdentifiers 3 | import CoreImage 4 | import AppKit 5 | import os 6 | import AudioToolbox 7 | 8 | protocol ResourceManaging { 9 | func cleanup() 10 | } 11 | 12 | class VideoProcessor: BaseConverter { 13 | private weak var processorFactory: ProcessorFactory? 14 | private let audioVisualizer: AudioVisualizer 15 | private let imageProcessor: ImageProcessor 16 | private let coordinator: ConversionCoordinator 17 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Convierto", category: "VideoProcessor") 18 | 19 | required init(settings: ConversionSettings = ConversionSettings()) throws { 20 | self.audioVisualizer = AudioVisualizer(size: CGSize(width: 1920, height: 1080)) 21 | self.imageProcessor = try ImageProcessor(settings: settings) 22 | self.coordinator = ConversionCoordinator(settings: settings) 23 | try super.init(settings: settings) 24 | } 25 | 26 | convenience init(settings: ConversionSettings = ConversionSettings(), factory: ProcessorFactory? = nil) throws { 27 | try self.init(settings: settings) 28 | self.processorFactory = factory 29 | } 30 | 31 | override func convert(_ url: URL, to format: UTType, metadata: ConversionMetadata, progress: Progress) async throws -> ProcessingResult { 32 | let conversionId = UUID() 33 | logger.debug("🎬 Starting video conversion (ID: \(conversionId.uuidString))") 34 | 35 | await coordinator.trackConversion(conversionId) 36 | 37 | defer { 38 | Task { 39 | logger.debug("🏁 Completing video conversion (ID: \(conversionId.uuidString))") 40 | await coordinator.untrackConversion(conversionId) 41 | } 42 | } 43 | 44 | logger.debug("📂 Input URL: \(url.path(percentEncoded: false))") 45 | logger.debug("🎯 Target format: \(format.identifier)") 46 | 47 | let asset = AVURLAsset(url: url) 48 | logger.debug("✅ Created AVURLAsset from provided URL") 49 | 50 | // Direct audio extraction for audio formats 51 | if format.conforms(to: .audio) || format == .mp3 { 52 | logger.debug("🎵 Using direct audio extraction") 53 | do { 54 | return try await extractAudio(from: asset, to: format, metadata: metadata, progress: progress) 55 | } catch { 56 | logger.error("❌ Audio extraction failed: \(error.localizedDescription)") 57 | // Try fallback with different settings 58 | return try await handleFallback( 59 | asset: asset, 60 | originalURL: url, 61 | to: format, 62 | metadata: metadata, 63 | progress: progress 64 | ) 65 | } 66 | } 67 | 68 | // Regular video conversion path 69 | return try await performConversion( 70 | asset: asset, 71 | originalURL: url, 72 | to: format, 73 | metadata: metadata, 74 | progress: progress 75 | ) 76 | } 77 | 78 | override func canConvert(from: UTType, to: UTType) -> Bool { 79 | // Allow direct audio extraction from audiovisual content 80 | if to.conforms(to: .audio) || to == .mp3 { 81 | return from.conforms(to: .audiovisualContent) || from.conforms(to: .audio) 82 | } 83 | 84 | // General video conversions 85 | if from.conforms(to: .audiovisualContent) || to.conforms(to: .audiovisualContent) { 86 | return true 87 | } 88 | 89 | return false 90 | } 91 | 92 | private func handleFallback( 93 | asset: AVAsset, 94 | originalURL: URL, 95 | to format: UTType, 96 | metadata: ConversionMetadata, 97 | progress: Progress 98 | ) async throws -> ProcessingResult { 99 | // If output is an image, try extracting a key frame 100 | if format.conforms(to: .image) { 101 | logger.debug("🎞 Fallback: Extracting key frame from video as image") 102 | return try await extractKeyFrame(from: asset, format: format, metadata: metadata) 103 | } 104 | 105 | // Otherwise, try with reduced quality settings 106 | let fallbackSettings = ConversionSettings( 107 | videoQuality: AVAssetExportPresetMediumQuality, 108 | videoBitRate: 1_000_000, 109 | audioBitRate: 64_000, 110 | frameRate: 24 111 | ) 112 | 113 | logger.debug("🎞 Fallback: Retrying conversion with reduced quality") 114 | return try await performConversion( 115 | asset: asset, 116 | originalURL: originalURL, 117 | to: format, 118 | metadata: metadata, 119 | progress: progress, 120 | settings: fallbackSettings 121 | ) 122 | } 123 | 124 | private func performConversion( 125 | asset: AVAsset, 126 | originalURL: URL, 127 | to format: UTType, 128 | metadata: ConversionMetadata, 129 | progress: Progress, 130 | settings: ConversionSettings = ConversionSettings() 131 | ) async throws -> ProcessingResult { 132 | let duration = try await asset.load(.duration) 133 | 134 | logger.debug("⚙️ Starting conversion process") 135 | let extensionForFormat = format.preferredFilenameExtension ?? "mp4" 136 | let outputURL = try CacheManager.shared.createTemporaryURL(for: extensionForFormat) 137 | 138 | logger.debug("📂 Output will be written to: \(outputURL.path)") 139 | 140 | guard let exportSession = AVAssetExportSession(asset: asset, presetName: settings.videoQuality) else { 141 | logger.error("❌ Failed to create AVAssetExportSession for the given asset and preset") 142 | throw ConversionError.exportFailed(reason: "Failed to create export session") 143 | } 144 | 145 | exportSession.outputURL = outputURL 146 | exportSession.outputFileType = getAVFileType(for: format) 147 | 148 | // Apply optional audio mix 149 | if let audioMix = try? await createAudioMix(for: asset) { 150 | logger.debug("🎵 Applying audio mix to export session") 151 | exportSession.audioMix = audioMix 152 | } 153 | 154 | // Apply video composition if needed 155 | if format.conforms(to: .audiovisualContent) { 156 | if let videoComposition = try? await createVideoComposition(for: asset) { 157 | logger.debug("🎥 Applying video composition to export session") 158 | exportSession.videoComposition = videoComposition 159 | } else { 160 | logger.debug("🎥 No video composition applied") 161 | } 162 | } 163 | 164 | logger.debug("▶️ Starting export session") 165 | 166 | let progressTask = Task { 167 | while !Task.isCancelled { 168 | let currentProgress = exportSession.progress 169 | progress.completedUnitCount = Int64(currentProgress * 100) 170 | logger.debug("📊 Export progress: \(Int(currentProgress * 100))%") 171 | try? await Task.sleep(nanoseconds: 100_000_000) 172 | if exportSession.status != .exporting { break } 173 | } 174 | } 175 | 176 | await exportSession.export() 177 | progressTask.cancel() 178 | 179 | switch exportSession.status { 180 | case .completed: 181 | logger.debug("✅ Export completed successfully") 182 | let resultMetadata = try await extractMetadata(from: asset) 183 | return ProcessingResult( 184 | outputURL: outputURL, 185 | originalFileName: metadata.originalFileName ?? originalURL.lastPathComponent, 186 | suggestedFileName: "converted_video." + extensionForFormat, 187 | fileType: format, 188 | metadata: resultMetadata 189 | ) 190 | case .failed: 191 | let errorMessage = exportSession.error?.localizedDescription ?? "Unknown error" 192 | logger.error("❌ Export failed: \(errorMessage)") 193 | throw ConversionError.conversionFailed(reason: "Export failed: \(errorMessage)") 194 | case .cancelled: 195 | logger.error("❌ Export cancelled") 196 | throw ConversionError.conversionFailed(reason: "Export cancelled") 197 | default: 198 | logger.error("❌ Export ended with unexpected status: \(exportSession.status.rawValue)") 199 | throw ConversionError.conversionFailed(reason: "Unexpected export status: \(exportSession.status.rawValue)") 200 | } 201 | } 202 | 203 | func extractKeyFrame(from asset: AVAsset, format: UTType, metadata: ConversionMetadata) async throws -> ProcessingResult { 204 | logger.debug("🖼 Extracting key frame from video") 205 | 206 | let generator = AVAssetImageGenerator(asset: asset) 207 | generator.appliesPreferredTrackTransform = true 208 | 209 | // Extract first frame 210 | let time = CMTime(seconds: 0, preferredTimescale: 600) 211 | let imageRef = try await generator.image(at: time).image 212 | 213 | let imageExtension = format.preferredFilenameExtension ?? "jpg" 214 | let outputURL = try CacheManager.shared.createTemporaryURL(for: imageExtension) 215 | 216 | let nsImage = NSImage(cgImage: imageRef, size: NSSize(width: imageRef.width, height: imageRef.height)) 217 | try await imageProcessor.saveImage(nsImage, format: format, to: outputURL, metadata: metadata) 218 | 219 | logger.debug("✅ Key frame extracted and saved") 220 | 221 | return ProcessingResult( 222 | outputURL: outputURL, 223 | originalFileName: metadata.originalFileName ?? "frame", 224 | suggestedFileName: "extracted_frame." + imageExtension, 225 | fileType: format, 226 | metadata: nil 227 | ) 228 | } 229 | 230 | private func createVideoComposition(for asset: AVAsset) async throws -> AVMutableVideoComposition { 231 | logger.debug("🎥 Creating video composition") 232 | 233 | guard let videoTrack = try await asset.loadTracks(withMediaType: .video).first else { 234 | logger.error("❌ No video track found for composition") 235 | throw ConversionError.conversionFailed(reason: "No video track found") 236 | } 237 | 238 | let trackSize = try await videoTrack.load(.naturalSize) 239 | let transform = try await videoTrack.load(.preferredTransform) 240 | let duration = try await asset.load(.duration) 241 | 242 | let targetSize = settings.maintainAspectRatio 243 | ? calculateAspectFitSize(trackSize, target: settings.targetSize) 244 | : settings.targetSize 245 | 246 | let composition = AVMutableVideoComposition() 247 | composition.renderSize = targetSize 248 | composition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(settings.frameRate)) 249 | 250 | let instruction = AVMutableVideoCompositionInstruction() 251 | instruction.timeRange = CMTimeRange(start: .zero, duration: duration) 252 | 253 | let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack) 254 | layerInstruction.setTransform(transform, at: .zero) 255 | 256 | instruction.layerInstructions = [layerInstruction] 257 | composition.instructions = [instruction] 258 | 259 | return composition 260 | } 261 | 262 | private func calculateAspectFitSize(_ originalSize: CGSize, target: CGSize) -> CGSize { 263 | let widthRatio = target.width / originalSize.width 264 | let heightRatio = target.height / originalSize.height 265 | let scale = min(widthRatio, heightRatio) 266 | return CGSize(width: originalSize.width * scale, height: originalSize.height * scale) 267 | } 268 | 269 | private func extractMetadata(from asset: AVAsset) async throws -> [String: Any] { 270 | var metadata: [String: Any] = [:] 271 | 272 | metadata["duration"] = try await asset.load(.duration).seconds 273 | metadata["preferredRate"] = try await asset.load(.preferredRate) 274 | metadata["preferredVolume"] = try await asset.load(.preferredVolume) 275 | 276 | if let format = try await asset.load(.availableMetadataFormats).first { 277 | let items = try await asset.loadMetadata(for: format) 278 | for item in items { 279 | if let key = item.commonKey?.rawValue, 280 | let value = try? await item.load(.value) { 281 | metadata[key] = value 282 | } 283 | } 284 | } 285 | 286 | return metadata 287 | } 288 | 289 | func extractAudio( 290 | from asset: AVAsset, 291 | to format: UTType, 292 | metadata: ConversionMetadata, 293 | progress: Progress 294 | ) async throws -> ProcessingResult { 295 | logger.debug("🎵 Starting direct audio extraction") 296 | 297 | guard let audioTrack = try await asset.loadTracks(withMediaType: .audio).first else { 298 | logger.error("❌ No audio track found in the video") 299 | throw ConversionError.conversionFailed(reason: "No audio track found in video") 300 | } 301 | 302 | // Create export session with audio-specific preset 303 | let preset = AVAssetExportPresetAppleM4A 304 | guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset) else { 305 | logger.error("❌ Failed to create export session for audio extraction") 306 | throw ConversionError.conversionFailed(reason: "Failed to create export session for audio") 307 | } 308 | 309 | // Set up export parameters 310 | let extensionForAudio = format == .mp3 ? "m4a" : format.preferredFilenameExtension ?? "m4a" 311 | let outputURL = try CacheManager.shared.createTemporaryURL(for: extensionForAudio) 312 | 313 | exportSession.outputURL = outputURL 314 | exportSession.outputFileType = .m4a 315 | exportSession.timeRange = CMTimeRange(start: .zero, duration: try await asset.load(.duration)) 316 | 317 | // Track progress 318 | let progressTask = Task { 319 | while !Task.isCancelled { 320 | let currentProgress = exportSession.progress 321 | progress.completedUnitCount = Int64(currentProgress * 100) 322 | logger.debug("📊 Audio export progress: \(Int(currentProgress * 100))%") 323 | try? await Task.sleep(nanoseconds: 100_000_000) 324 | if exportSession.status != .exporting { break } 325 | } 326 | } 327 | 328 | logger.debug("▶️ Starting audio extraction") 329 | await exportSession.export() 330 | progressTask.cancel() 331 | 332 | switch exportSession.status { 333 | case .completed: 334 | logger.debug("✅ Audio extraction completed") 335 | 336 | // If MP3 is requested, convert M4A to MP3 337 | if format == .mp3 { 338 | return try await convertM4AToMP3(outputURL, metadata: metadata) 339 | } 340 | 341 | let resultMetadata = try await extractMetadata(from: asset) 342 | return ProcessingResult( 343 | outputURL: outputURL, 344 | originalFileName: metadata.originalFileName ?? "audio", 345 | suggestedFileName: "extracted_audio." + extensionForAudio, 346 | fileType: format, 347 | metadata: resultMetadata 348 | ) 349 | 350 | case .failed: 351 | let errorMessage = exportSession.error?.localizedDescription ?? "Unknown error" 352 | logger.error("❌ Audio extraction failed: \(errorMessage)") 353 | throw ConversionError.exportFailed(reason: "Failed to extract audio: \(errorMessage)") 354 | 355 | case .cancelled: 356 | logger.error("❌ Audio extraction cancelled") 357 | throw ConversionError.exportFailed(reason: "Audio extraction was cancelled") 358 | 359 | default: 360 | let statusRaw = exportSession.status.rawValue 361 | logger.error("❌ Audio extraction ended with unexpected status: \(statusRaw)") 362 | throw ConversionError.exportFailed(reason: "Unexpected export status: \(statusRaw)") 363 | } 364 | } 365 | 366 | private func convertM4AToMP3(_ inputURL: URL, metadata: ConversionMetadata) async throws -> ProcessingResult { 367 | logger.debug("🎵 Converting M4A to MP3") 368 | 369 | // First create a temporary M4A file 370 | let tempM4AURL = try await CacheManager.shared.createTemporaryURL(for: "m4a") 371 | let asset = AVURLAsset(url: inputURL) 372 | 373 | // Create export session with audio preset 374 | guard let exportSession = AVAssetExportSession( 375 | asset: asset, 376 | presetName: AVAssetExportPresetAppleM4A 377 | ) else { 378 | throw ConversionError.exportFailed(reason: "Failed to create export session for MP3 conversion") 379 | } 380 | 381 | exportSession.outputURL = tempM4AURL 382 | exportSession.outputFileType = .m4a 383 | exportSession.audioTimePitchAlgorithm = .spectral 384 | 385 | logger.debug("▶️ Starting M4A export") 386 | await exportSession.export() 387 | 388 | switch exportSession.status { 389 | case .completed: 390 | logger.debug("✅ M4A export completed successfully") 391 | 392 | // Create final MP3 URL 393 | let mp3URL = try await CacheManager.shared.createTemporaryURL(for: "mp3") 394 | 395 | // Ensure we have write permissions for the output directory 396 | let outputDirectory = mp3URL.deletingLastPathComponent() 397 | try FileManager.default.createDirectory( 398 | at: outputDirectory, 399 | withIntermediateDirectories: true, 400 | attributes: nil 401 | ) 402 | 403 | // Remove any existing file at the destination 404 | if FileManager.default.fileExists(atPath: mp3URL.path) { 405 | try FileManager.default.removeItem(at: mp3URL) 406 | } 407 | 408 | // Move the M4A file to MP3 409 | try FileManager.default.moveItem(at: tempM4AURL, to: mp3URL) 410 | 411 | logger.debug("✅ File successfully moved to: \(mp3URL.path)") 412 | 413 | return ProcessingResult( 414 | outputURL: mp3URL, 415 | originalFileName: metadata.originalFileName ?? "audio", 416 | suggestedFileName: "converted_audio.mp3", 417 | fileType: .mp3, 418 | metadata: metadata.toDictionary() 419 | ) 420 | 421 | case .failed: 422 | let errorMessage = exportSession.error?.localizedDescription ?? "Unknown error" 423 | logger.error("❌ MP3 conversion failed: \(errorMessage)") 424 | throw ConversionError.exportFailed(reason: "Failed to convert to MP3: \(errorMessage)") 425 | 426 | case .cancelled: 427 | logger.error("❌ MP3 conversion cancelled") 428 | throw ConversionError.exportFailed(reason: "MP3 conversion was cancelled") 429 | 430 | default: 431 | let statusRaw = exportSession.status.rawValue 432 | logger.error("❌ MP3 conversion ended with unexpected status: \(statusRaw)") 433 | throw ConversionError.exportFailed(reason: "Unexpected export status: \(statusRaw)") 434 | } 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.850", 9 | "green" : "0.470", 10 | "red" : "0.250" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256 1.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32 1.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512 1.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/convierto/5db0789e073092053c1d91db2b43281261b37a95/Convierto/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-64.png -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon-16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "AppIcon-32 1.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "AppIcon-32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "AppIcon-64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "AppIcon-128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "AppIcon-256 1.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "AppIcon-256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "AppIcon-512 1.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "AppIcon-512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "AppIcon-1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Convierto/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Convierto/Resources/ConversionSettings.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | import AVFoundation 4 | 5 | public struct ConversionSettings { 6 | // Image conversion settings 7 | var imageQuality: CGFloat = 0.95 8 | var preserveMetadata: Bool = true 9 | var maintainAspectRatio: Bool = true 10 | var resizeImage: Bool = false 11 | var targetSize: CGSize = CGSize(width: 1920, height: 1080) 12 | var enhanceImage: Bool = false 13 | var adjustColors: Bool = false 14 | var saturation: Double = 1.0 15 | var brightness: Double = 0.0 16 | var contrast: Double = 1.0 17 | 18 | // Video conversion settings 19 | var videoQuality: String = AVAssetExportPresetHighestQuality 20 | var videoBitRate: Int = 10_000_000 21 | var audioBitRate: Int = 256_000 22 | var frameRate: Int = 30 23 | var videoDuration: Double = 10.0 24 | 25 | // Animation settings 26 | var gifFrameCount: Int = 10 27 | var gifFrameDuration: Double = 0.1 28 | var animationStyle: AnimationStyle = .none 29 | 30 | // Audio mixing settings 31 | var audioStartVolume: Float = 1.0 32 | var audioEndVolume: Float = 1.0 33 | 34 | // Memory thresholds 35 | var memoryThresholdPercentage: Double = 0.5 // Use up to 50% of available memory 36 | 37 | // Validation thresholds 38 | var minimumVideoBitRate: Int = 100_000 // 100 Kbps 39 | var minimumAudioBitRate: Int = 64_000 // 64 Kbps 40 | 41 | public enum AnimationStyle { 42 | case none 43 | case zoom 44 | case rotate 45 | } 46 | 47 | public init( 48 | imageQuality: Double = 0.8, 49 | preserveMetadata: Bool = true, 50 | maintainAspectRatio: Bool = true, 51 | resizeImage: Bool = false, 52 | targetSize: CGSize = CGSize(width: 1920, height: 1080), 53 | enhanceImage: Bool = false, 54 | adjustColors: Bool = false, 55 | saturation: Double = 1.0, 56 | brightness: Double = 0.0, 57 | contrast: Double = 1.0, 58 | videoQuality: String = AVAssetExportPresetHighestQuality, 59 | videoBitRate: Int = 10_000_000, 60 | audioBitRate: Int = 256_000, 61 | frameRate: Int = 30, 62 | videoDuration: Double = 10.0, 63 | gifFrameCount: Int = 10, 64 | gifFrameDuration: Double = 0.1, 65 | animationStyle: AnimationStyle = .none 66 | ) { 67 | self.imageQuality = imageQuality 68 | self.preserveMetadata = preserveMetadata 69 | self.maintainAspectRatio = maintainAspectRatio 70 | self.resizeImage = resizeImage 71 | self.targetSize = targetSize 72 | self.enhanceImage = enhanceImage 73 | self.adjustColors = adjustColors 74 | self.saturation = saturation 75 | self.brightness = brightness 76 | self.contrast = contrast 77 | self.videoQuality = videoQuality 78 | self.videoBitRate = videoBitRate 79 | self.audioBitRate = audioBitRate 80 | self.frameRate = frameRate 81 | self.videoDuration = videoDuration 82 | self.gifFrameCount = gifFrameCount 83 | self.gifFrameDuration = gifFrameDuration 84 | self.animationStyle = animationStyle 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Convierto/Resources/FileDropDelegate.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UniformTypeIdentifiers 3 | import os.log 4 | 5 | private let logger = Logger( 6 | subsystem: Bundle.main.bundleIdentifier ?? "Convierto", 7 | category: "FileDropDelegate" 8 | ) 9 | 10 | struct FileDropDelegate: DropDelegate { 11 | @Binding var isDragging: Bool 12 | let supportedTypes: [UTType] 13 | let handleDrop: @MainActor ([NSItemProvider]) -> Void 14 | 15 | func validateDrop(info: DropInfo) -> Bool { 16 | logger.debug("Validating drop...") 17 | return info.hasItemsConforming(to: [.fileURL]) 18 | } 19 | 20 | func performDrop(info: DropInfo) -> Bool { 21 | logger.debug("Performing drop") 22 | isDragging = false 23 | let providers = info.itemProviders(for: [.fileURL]) 24 | 25 | Task { @MainActor in 26 | handleDrop(providers) 27 | } 28 | 29 | return true 30 | } 31 | 32 | func dropEntered(info: DropInfo) { 33 | logger.debug("Drop entered") 34 | withAnimation(.easeInOut(duration: 0.2)) { 35 | isDragging = validateDrop(info: info) 36 | } 37 | } 38 | 39 | func dropExited(info: DropInfo) { 40 | logger.debug("Drop exited") 41 | withAnimation(.easeInOut(duration: 0.2)) { 42 | isDragging = false 43 | } 44 | } 45 | } 46 | 47 | @MainActor 48 | extension NSItemProvider { 49 | func loadURL() async throws -> URL? { 50 | logger.debug("Loading URL from item provider") 51 | return try await withCheckedThrowingContinuation { continuation in 52 | loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, error in 53 | if let error = error { 54 | logger.error("Failed to load URL: \(error.localizedDescription)") 55 | continuation.resume(throwing: error) 56 | } else if let urlData = data as? Data, 57 | let url = URL(dataRepresentation: urlData, relativeTo: nil) { 58 | logger.debug("Successfully loaded URL: \(url.path)") 59 | continuation.resume(returning: url) 60 | } else if let url = data as? URL { 61 | logger.debug("Successfully loaded direct URL: \(url.path)") 62 | continuation.resume(returning: url) 63 | } else { 64 | logger.error("Item is not a URL") 65 | continuation.resume(returning: nil) 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | @MainActor 73 | class FileDropHandler { 74 | private let logger = Logger( 75 | subsystem: Bundle.main.bundleIdentifier ?? "Convierto", 76 | category: "FileDropHandler" 77 | ) 78 | 79 | func handleProviders(_ providers: [NSItemProvider], outputFormat: UTType) async throws -> [URL] { 80 | var urls: [URL] = [] 81 | 82 | for provider in providers { 83 | guard provider.canLoadObject(ofClass: URL.self) else { 84 | throw ConversionError.invalidInput 85 | } 86 | 87 | if let url = try await provider.loadURL() { 88 | logger.debug("Processing URL: \(url.path)") 89 | 90 | // Create security-scoped bookmark 91 | let bookmarkData = try url.bookmarkData( 92 | options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], 93 | includingResourceValuesForKeys: nil, 94 | relativeTo: nil 95 | ) 96 | 97 | var isStale = false 98 | guard let resolvedURL = try? URL( 99 | resolvingBookmarkData: bookmarkData, 100 | options: .withSecurityScope, 101 | relativeTo: nil, 102 | bookmarkDataIsStale: &isStale 103 | ) else { 104 | logger.error("Failed to resolve bookmark for URL: \(url.path)") 105 | throw ConversionError.fileAccessDenied(path: url.path) 106 | } 107 | 108 | guard resolvedURL.startAccessingSecurityScopedResource() else { 109 | logger.error("Failed to access security-scoped resource: \(resolvedURL.path)") 110 | throw ConversionError.sandboxViolation(reason: "Cannot access security-scoped resource") 111 | } 112 | 113 | defer { 114 | resolvedURL.stopAccessingSecurityScopedResource() 115 | } 116 | 117 | // Verify file exists and is readable 118 | guard FileManager.default.isReadableFile(atPath: resolvedURL.path) else { 119 | logger.error("File is not readable: \(resolvedURL.path)") 120 | throw ConversionError.fileAccessDenied(path: resolvedURL.path) 121 | } 122 | 123 | urls.append(resolvedURL) 124 | } 125 | } 126 | 127 | guard !urls.isEmpty else { 128 | throw ConversionError.invalidInput 129 | } 130 | 131 | return urls 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Convierto/Resources/UpdateChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GitHubRelease: Codable { 4 | let tagName: String 5 | let name: String 6 | let body: String 7 | let htmlUrl: String 8 | 9 | enum CodingKeys: String, CodingKey { 10 | case tagName = "tag_name" 11 | case name 12 | case body 13 | case htmlUrl = "html_url" 14 | } 15 | } 16 | 17 | class UpdateChecker: ObservableObject { 18 | @Published var updateAvailable = false 19 | @Published var latestVersion: String? 20 | @Published var releaseNotes: String? 21 | @Published var downloadURL: URL? 22 | @Published var isChecking = false 23 | @Published var error: String? 24 | @Published var statusIcon: String = "checkmark.circle" 25 | 26 | var onStatusChange: ((String) -> Void)? 27 | var onUpdateAvailable: (() -> Void)? 28 | 29 | private let currentVersion: String 30 | private let githubRepo: String 31 | private var updateCheckTimer: Timer? 32 | 33 | init() { 34 | self.currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" 35 | self.githubRepo = "nuance-dev/Convierto" 36 | setupTimer() 37 | updateStatusIcon() 38 | } 39 | 40 | private func setupTimer() { 41 | // Initial check after 2 seconds 42 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in 43 | self?.checkForUpdates() 44 | } 45 | 46 | // Periodic check every 24 hours 47 | updateCheckTimer = Timer.scheduledTimer(withTimeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in 48 | self?.checkForUpdates() 49 | } 50 | } 51 | 52 | private func updateStatusIcon() { 53 | DispatchQueue.main.async { [weak self] in 54 | guard let self = self else { return } 55 | if self.isChecking { 56 | self.statusIcon = "arrow.triangle.2.circlepath" 57 | } else { 58 | self.statusIcon = self.updateAvailable ? "exclamationmark.circle" : "checkmark.circle" 59 | } 60 | self.onStatusChange?(self.statusIcon) 61 | } 62 | } 63 | 64 | func checkForUpdates() { 65 | print("Checking for updates...") 66 | print("Current version: \(currentVersion)") 67 | 68 | isChecking = true 69 | updateStatusIcon() 70 | error = nil 71 | 72 | let baseURL = "https://api.github.com/repos/\(githubRepo)/releases/latest" 73 | guard let url = URL(string: baseURL) else { 74 | error = "Invalid GitHub repository URL" 75 | isChecking = false 76 | updateStatusIcon() 77 | return 78 | } 79 | 80 | var request = URLRequest(url: url) 81 | request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") 82 | request.setValue("Convierto-App/\(currentVersion)", forHTTPHeaderField: "User-Agent") 83 | 84 | URLSession.shared.dataTask(with: request) { [weak self] data, response, error in 85 | DispatchQueue.main.async { 86 | self?.handleUpdateResponse(data: data, response: response as? HTTPURLResponse, error: error) 87 | } 88 | }.resume() 89 | } 90 | 91 | private func handleUpdateResponse(data: Data?, response: HTTPURLResponse?, error: Error?) { 92 | defer { 93 | isChecking = false 94 | updateStatusIcon() 95 | } 96 | 97 | if let error = error { 98 | print("Network error: \(error)") 99 | self.error = "Network error: \(error.localizedDescription)" 100 | return 101 | } 102 | 103 | guard let response = response else { 104 | print("Invalid response") 105 | self.error = "Invalid response from server" 106 | return 107 | } 108 | 109 | print("Response status code: \(response.statusCode)") 110 | 111 | guard response.statusCode == 200 else { 112 | self.error = "Server error: \(response.statusCode)" 113 | return 114 | } 115 | 116 | guard let data = data else { 117 | self.error = "No data received" 118 | return 119 | } 120 | 121 | do { 122 | 123 | let decoder = JSONDecoder() 124 | let release = try decoder.decode(GitHubRelease.self, from: data) 125 | 126 | let cleanLatestVersion = release.tagName.replacingOccurrences(of: "v", with: "") 127 | print("Latest version: \(cleanLatestVersion)") 128 | print("Current version for comparison: \(currentVersion)") 129 | 130 | updateAvailable = compareVersions(current: currentVersion, latest: cleanLatestVersion) 131 | if updateAvailable { 132 | DispatchQueue.main.async { 133 | self.onUpdateAvailable?() 134 | } 135 | } 136 | 137 | latestVersion = cleanLatestVersion 138 | releaseNotes = release.body 139 | downloadURL = URL(string: release.htmlUrl) 140 | 141 | updateAvailable = compareVersions(current: currentVersion, latest: cleanLatestVersion) 142 | print("Update available: \(updateAvailable)") 143 | 144 | } catch { 145 | print("Parsing error: \(error)") 146 | self.error = "Failed to parse response: \(error.localizedDescription)" 147 | } 148 | } 149 | 150 | private func compareVersions(current: String, latest: String) -> Bool { 151 | // Clean and split versions 152 | let currentParts = current.replacingOccurrences(of: "v", with: "") 153 | .split(separator: ".") 154 | .compactMap { Int($0) } 155 | 156 | let latestParts = latest.replacingOccurrences(of: "v", with: "") 157 | .split(separator: ".") 158 | .compactMap { Int($0) } 159 | 160 | 161 | // Ensure we have at least 3 components (major.minor.patch) 162 | let paddedCurrent = currentParts + Array(repeating: 0, count: max(3 - currentParts.count, 0)) 163 | let paddedLatest = latestParts + Array(repeating: 0, count: max(3 - latestParts.count, 0)) 164 | 165 | 166 | // Compare each version component 167 | for i in 0.. paddedCurrent[i] { 169 | return true 170 | } else if paddedLatest[i] < paddedCurrent[i] { 171 | return false 172 | } 173 | } 174 | 175 | print("Versions are equal") 176 | return false 177 | } 178 | 179 | deinit { 180 | updateCheckTimer?.invalidate() 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Convierto/UI Components/ButtonGroup.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ToolbarButton: View { 4 | let title: String 5 | let icon: String 6 | let action: () -> Void 7 | let isFirst: Bool 8 | let isLast: Bool 9 | 10 | var body: some View { 11 | Button(action: action) { 12 | HStack(spacing: 6) { 13 | Image(systemName: icon) 14 | .font(.system(size: 14)) 15 | Text(title) 16 | .font(.system(size: 13, weight: .medium)) 17 | } 18 | .frame(height: 36) 19 | .padding(.horizontal, 16) 20 | .foregroundColor(.primary) 21 | .background(Color.clear) 22 | .contentShape(Rectangle()) 23 | } 24 | .buttonStyle(PlainButtonStyle()) 25 | } 26 | } 27 | 28 | struct ButtonDivider: View { 29 | var body: some View { 30 | Divider() 31 | .frame(height: 24) 32 | } 33 | } 34 | 35 | struct ButtonGroup: View { 36 | let buttons: [(title: String, icon: String, action: () -> Void)] 37 | 38 | var body: some View { 39 | HStack(spacing: 0) { 40 | ForEach(Array(buttons.enumerated()), id: \.offset) { index, button in 41 | if index > 0 { 42 | ButtonDivider() 43 | } 44 | 45 | ToolbarButton( 46 | title: button.title, 47 | icon: button.icon, 48 | action: button.action, 49 | isFirst: index == 0, 50 | isLast: index == buttons.count - 1 51 | ) 52 | } 53 | } 54 | .background(backgroundView) 55 | } 56 | 57 | private var backgroundView: some View { 58 | ZStack { 59 | // Base background 60 | RoundedRectangle(cornerRadius: 12) 61 | .fill(Color(NSColor.windowBackgroundColor).opacity(0.5)) 62 | 63 | // Subtle border 64 | RoundedRectangle(cornerRadius: 12) 65 | .strokeBorder(Color.primary.opacity(0.1), lineWidth: 1) 66 | 67 | // Glass effect overlay 68 | RoundedRectangle(cornerRadius: 12) 69 | .stroke(Color.white.opacity(0.1), lineWidth: 1) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Convierto/UI Components/GlassButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct GlassButtonStyle: ButtonStyle { 4 | func makeBody(configuration: Configuration) -> some View { 5 | configuration.label 6 | .padding(.horizontal, 20) 7 | .padding(.vertical, 10) 8 | .background( 9 | RoundedRectangle(cornerRadius: 8) 10 | .fill(Color.primary.opacity(0.1)) 11 | .overlay( 12 | RoundedRectangle(cornerRadius: 8) 13 | .stroke(Color.primary.opacity(0.2), lineWidth: 1) 14 | ) 15 | ) 16 | .opacity(configuration.isPressed ? 0.8 : 1.0) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Convierto/UI Components/TitleBarAccessory.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TitleBarAccessory: View { 4 | @AppStorage("isDarkMode") private var isDarkMode = false 5 | 6 | var body: some View { 7 | Button(action: { 8 | isDarkMode.toggle() 9 | }) { 10 | Image(systemName: isDarkMode ? "sun.max.fill" : "moon.fill") 11 | .foregroundColor(.primary) 12 | } 13 | .buttonStyle(PlainButtonStyle()) 14 | .frame(width: 30, height: 30) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Convierto/UI Components/VisualEffectBlur.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct VisualEffectBlur: NSViewRepresentable { 4 | var material: NSVisualEffectView.Material 5 | var blendingMode: NSVisualEffectView.BlendingMode 6 | 7 | func makeNSView(context: Context) -> NSVisualEffectView { 8 | let view = NSVisualEffectView() 9 | view.state = .active 10 | view.material = material 11 | view.blendingMode = blendingMode 12 | view.alphaValue = 0.9 13 | return view 14 | } 15 | 16 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} 17 | } 18 | -------------------------------------------------------------------------------- /Convierto/UI Components/WindowAccessor.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct WindowAccessor: NSViewRepresentable { 4 | func makeNSView(context: Context) -> NSView { 5 | let nsView = NSView() 6 | 7 | DispatchQueue.main.async { 8 | if let window = nsView.window { 9 | let titleBarAccessory = NSTitlebarAccessoryViewController() 10 | let hostingView = NSHostingView(rootView: TitleBarAccessory()) 11 | 12 | hostingView.frame.size = hostingView.fittingSize 13 | titleBarAccessory.view = hostingView 14 | titleBarAccessory.layoutAttribute = .trailing 15 | 16 | window.addTitlebarAccessoryViewController(titleBarAccessory) 17 | } 18 | } 19 | 20 | return nsView 21 | } 22 | 23 | func updateNSView(_ nsView: NSView, context: Context) {} 24 | } 25 | -------------------------------------------------------------------------------- /Convierto/Utilities/ConversionMetadata.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | 4 | struct ConversionMetadata { 5 | let originalFileName: String? 6 | let originalFileType: UTType? 7 | let creationDate: Date? 8 | let modificationDate: Date? 9 | let fileSize: Int64? 10 | let additionalMetadata: [String: Any]? 11 | 12 | init( 13 | originalFileName: String? = nil, 14 | originalFileType: UTType? = nil, 15 | creationDate: Date? = nil, 16 | modificationDate: Date? = nil, 17 | fileSize: Int64? = nil, 18 | additionalMetadata: [String: Any]? = nil 19 | ) { 20 | self.originalFileName = originalFileName 21 | self.originalFileType = originalFileType 22 | self.creationDate = creationDate 23 | self.modificationDate = modificationDate 24 | self.fileSize = fileSize 25 | self.additionalMetadata = additionalMetadata 26 | } 27 | } 28 | 29 | extension ConversionMetadata { 30 | func toDictionary() -> [String: Any] { 31 | var dict: [String: Any] = [:] 32 | if let fileName = originalFileName { 33 | dict["originalFileName"] = fileName 34 | } 35 | if let fileType = originalFileType { 36 | dict["originalFileType"] = fileType 37 | } 38 | if let created = creationDate { 39 | dict["creationDate"] = created 40 | } 41 | if let modified = modificationDate { 42 | dict["modificationDate"] = modified 43 | } 44 | dict["fileSize"] = fileSize 45 | return dict 46 | } 47 | } -------------------------------------------------------------------------------- /Convierto/Utilities/FileValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | 4 | class FileValidator { 5 | private let maxFileSizes: [UTType: Int64] = [ 6 | .image: 500_000_000, // 500MB for images 7 | .audio: 1_000_000_000, // 1GB for audio 8 | .audiovisualContent: 2_000_000_000, // 2GB for video 9 | .pdf: 500_000_000 // 500MB for PDFs 10 | ] 11 | 12 | func validateFile(_ url: URL) async throws { 13 | // Check if file exists and is readable 14 | guard FileManager.default.isReadableFile(atPath: url.path) else { 15 | throw ConversionError.invalidInput 16 | } 17 | 18 | // Get file attributes 19 | let resourceValues = try url.resourceValues(forKeys: [.contentTypeKey, .fileSizeKey]) 20 | 21 | // Validate file type 22 | guard let fileType = resourceValues.contentType else { 23 | throw ConversionError.invalidInput 24 | } 25 | 26 | // Check file size 27 | if let fileSize = resourceValues.fileSize { 28 | let maxSize = getMaxFileSize(for: fileType) 29 | if fileSize > maxSize { 30 | throw ConversionError.invalidInput 31 | } 32 | } 33 | } 34 | 35 | private func getMaxFileSize(for type: UTType) -> Int64 { 36 | for (baseType, maxSize) in maxFileSizes { 37 | if type.conforms(to: baseType) { 38 | return maxSize 39 | } 40 | } 41 | return 100_000_000 // Default to 100MB 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Convierto/Utilities/GraphicsContextManager.swift: -------------------------------------------------------------------------------- 1 | import CoreImage 2 | import Foundation 3 | import os.log 4 | 5 | class GraphicsContextManager { 6 | static let shared = GraphicsContextManager() 7 | private let queue = DispatchQueue(label: "com.convierto.graphics", qos: .userInitiated) 8 | private var contexts: [String: (context: CIContext, lastUsed: Date)] = [:] 9 | private let lock = NSLock() 10 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Convierto", category: "GraphicsContextManager") 11 | 12 | private let maxContexts = 3 13 | private let contextTimeout: TimeInterval = 30 14 | private let options: [CIContextOption: Any] = [ 15 | .cacheIntermediates: false, 16 | .allowLowPower: true, 17 | .priorityRequestLow: true 18 | ] 19 | 20 | private init() { 21 | setupContextCleanupTimer() 22 | } 23 | 24 | private func setupContextCleanupTimer() { 25 | Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in 26 | self?.cleanupUnusedContexts() 27 | } 28 | } 29 | 30 | func context(for key: String) -> CIContext { 31 | lock.lock() 32 | defer { lock.unlock() } 33 | 34 | if let existing = contexts[key] { 35 | contexts[key] = (existing.context, Date()) 36 | return existing.context 37 | } 38 | 39 | cleanupUnusedContexts() 40 | 41 | let context = CIContext(options: options) 42 | contexts[key] = (context, Date()) 43 | return context 44 | } 45 | 46 | func releaseContext(for key: String) { 47 | lock.lock() 48 | defer { lock.unlock() } 49 | 50 | contexts.removeValue(forKey: key) 51 | } 52 | 53 | private func cleanupUnusedContexts() { 54 | let now = Date() 55 | contexts = contexts.filter { key, value in 56 | if now.timeIntervalSince(value.lastUsed) > contextTimeout { 57 | logger.debug("Releasing unused context: \(key)") 58 | return false 59 | } 60 | return true 61 | } 62 | } 63 | 64 | func releaseAllContexts() { 65 | lock.lock() 66 | defer { lock.unlock() } 67 | 68 | contexts.removeAll() 69 | } 70 | 71 | func monitorMemoryPressure() { 72 | DispatchQueue.global(qos: .utility).async { [weak self] in 73 | let observer = DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical]) 74 | observer.setEventHandler { [weak self] in 75 | self?.handleMemoryPressure() 76 | } 77 | observer.resume() 78 | } 79 | } 80 | 81 | private func handleMemoryPressure() { 82 | lock.lock() 83 | defer { lock.unlock() } 84 | 85 | // Release all contexts except the most recently used one 86 | let sortedContexts = contexts.sorted { $0.value.lastUsed > $1.value.lastUsed } 87 | if sortedContexts.count > 1 { 88 | for (key, _) in sortedContexts[1...] { 89 | contexts.removeValue(forKey: key) 90 | } 91 | } 92 | 93 | logger.debug("Memory pressure handled: released \(sortedContexts.count - 1) contexts") 94 | } 95 | 96 | func releaseContextSync(for key: String) { 97 | lock.lock() 98 | defer { lock.unlock() } 99 | contexts.removeValue(forKey: key) 100 | } 101 | } -------------------------------------------------------------------------------- /Convierto/Utilities/LoggerSetup.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import os.log 3 | 4 | extension Logger { 5 | static func makeLogger(category: String) -> Logger { 6 | Logger(subsystem: Bundle.main.bundleIdentifier ?? "Convierto", category: category) 7 | } 8 | } -------------------------------------------------------------------------------- /Convierto/Utilities/MemoryPressureHandler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum MemoryPressure { 4 | case none 5 | case warning 6 | case critical 7 | } 8 | 9 | class MemoryPressureHandler { 10 | var onPressureChange: ((MemoryPressure) -> Void)? 11 | private var observation: NSObjectProtocol? 12 | private var timer: Timer? 13 | 14 | init() { 15 | setupObserver() 16 | startMonitoring() 17 | } 18 | 19 | deinit { 20 | if let observation = observation { 21 | NotificationCenter.default.removeObserver(observation) 22 | } 23 | timer?.invalidate() 24 | } 25 | 26 | private func setupObserver() { 27 | observation = NotificationCenter.default.addObserver( 28 | forName: .memoryPressureWarning, 29 | object: nil, 30 | queue: .main 31 | ) { [weak self] _ in 32 | self?.onPressureChange?(.warning) 33 | } 34 | } 35 | 36 | private func startMonitoring() { 37 | timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in 38 | self?.checkMemoryPressure() 39 | } 40 | } 41 | 42 | private func checkMemoryPressure() { 43 | let hostPort = mach_host_self() 44 | var hostSize = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) 45 | var hostInfo = vm_statistics64_data_t() 46 | 47 | let result = withUnsafeMutablePointer(to: &hostInfo) { 48 | $0.withMemoryRebound(to: integer_t.self, capacity: Int(hostSize)) { 49 | host_statistics64(hostPort, HOST_VM_INFO64, $0, &hostSize) 50 | } 51 | } 52 | 53 | if result == KERN_SUCCESS { 54 | let totalMemory = Int64(hostInfo.wire_count + hostInfo.active_count + hostInfo.inactive_count + hostInfo.free_count) * Int64(vm_page_size) 55 | let freeMemory = Int64(hostInfo.free_count) * Int64(vm_page_size) 56 | let usedPercentage = Double(totalMemory - freeMemory) / Double(totalMemory) 57 | 58 | if usedPercentage > 0.9 { 59 | onPressureChange?(.critical) 60 | } else if usedPercentage > 0.8 { 61 | onPressureChange?(.warning) 62 | } else { 63 | onPressureChange?(.none) 64 | } 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /Convierto/Utilities/NotificationNames.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Notification.Name { 4 | static let memoryPressureWarning = Notification.Name("com.convierto.memoryPressureWarning") 5 | static let processingStageChanged = Notification.Name("processingStageChanged") 6 | static let processingProgressUpdated = Notification.Name("processingProgressUpdated") 7 | } -------------------------------------------------------------------------------- /Convierto/Utilities/ProgressTracker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import SwiftUI 4 | 5 | class ProgressTracker: ObservableObject { 6 | @Published private(set) var progress: Double = 0 7 | @Published private(set) var currentStage: ConversionStage = .preparing 8 | @Published private(set) var isIndeterminate: Bool = false 9 | @Published private(set) var statusMessage: String = "" 10 | 11 | private var subOperations: [String: Double] = [:] 12 | private var observations: Set = [] 13 | private let lock = NSLock() 14 | 15 | enum ConversionStage: String { 16 | case preparing = "Preparing" 17 | case loading = "Loading Resources" 18 | case analyzing = "Analyzing" 19 | case processing = "Converting" 20 | case optimizing = "Optimizing" 21 | case exporting = "Exporting" 22 | case finishing = "Finishing Up" 23 | case failed = "Failed" 24 | case completed = "Completed" 25 | 26 | var stageMessage: String { 27 | return self.rawValue 28 | } 29 | 30 | var systemImage: String { 31 | switch self { 32 | case .preparing: return "gear" 33 | case .loading: return "arrow.down.circle" 34 | case .analyzing: return "magnifyingglass" 35 | case .processing: return "wand.and.stars" 36 | case .optimizing: return "slider.horizontal.3" 37 | case .exporting: return "square.and.arrow.up" 38 | case .finishing: return "checkmark.circle" 39 | case .failed: return "exclamationmark.triangle" 40 | case .completed: return "checkmark.circle.fill" 41 | } 42 | } 43 | } 44 | 45 | func trackProgress(of progress: Progress, for operation: String) { 46 | progress.publisher(for: \.fractionCompleted) 47 | .receive(on: DispatchQueue.main) 48 | .sink { [weak self] value in 49 | self?.updateProgress(for: operation, progress: value) 50 | } 51 | .store(in: &observations) 52 | } 53 | 54 | func updateProgress(for operation: String, progress: Double) { 55 | lock.lock() 56 | defer { lock.unlock() } 57 | 58 | subOperations[operation] = progress 59 | calculateOverallProgress() 60 | } 61 | 62 | func setStage(_ stage: ConversionStage, message: String? = nil) { 63 | DispatchQueue.main.async { 64 | self.currentStage = stage 65 | if let message = message { 66 | self.statusMessage = message 67 | } 68 | } 69 | } 70 | 71 | func setIndeterminate(_ indeterminate: Bool) { 72 | DispatchQueue.main.async { 73 | self.isIndeterminate = indeterminate 74 | } 75 | } 76 | 77 | private func calculateOverallProgress() { 78 | let total = subOperations.values.reduce(0, +) 79 | let count = Double(max(1, subOperations.count)) 80 | let overall = total / count 81 | 82 | DispatchQueue.main.async { 83 | self.progress = min(1.0, max(0.0, overall)) 84 | } 85 | } 86 | 87 | func reset() { 88 | lock.lock() 89 | defer { lock.unlock() } 90 | 91 | subOperations.removeAll() 92 | observations.removeAll() 93 | 94 | DispatchQueue.main.async { 95 | self.progress = 0 96 | self.currentStage = .preparing 97 | self.isIndeterminate = false 98 | self.statusMessage = "" 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /Convierto/Utilities/ResourceManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import AppKit 3 | import os.log 4 | 5 | class ResourceManager { 6 | static let shared = ResourceManager() 7 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "Convierto", category: "ResourceManager") 8 | 9 | private let memoryLimit: UInt64 = 1024 * 1024 * 1024 // 1GB 10 | private var activeContexts: Set = [] 11 | private let queue = DispatchQueue(label: "com.convierto.resources") 12 | private let lock = NSLock() 13 | 14 | private var memoryWarningObserver: NSObjectProtocol? 15 | 16 | init() { 17 | setupMemoryWarningObserver() 18 | } 19 | 20 | func canAllocateMemory(bytes: UInt64) -> Bool { 21 | let free = ProcessInfo.processInfo.physicalMemory 22 | return bytes < free / 2 23 | } 24 | 25 | func trackContext(_ identifier: String) { 26 | lock.lock() 27 | defer { lock.unlock() } 28 | activeContexts.insert(identifier) 29 | } 30 | 31 | func releaseContext(_ identifier: String) { 32 | lock.lock() 33 | defer { lock.unlock() } 34 | activeContexts.remove(identifier) 35 | GraphicsContextManager.shared.releaseContext(for: identifier) 36 | } 37 | 38 | func cleanup() { 39 | lock.lock() 40 | defer { lock.unlock() } 41 | 42 | activeContexts.forEach { contextId in 43 | GraphicsContextManager.shared.releaseContext(for: contextId) 44 | } 45 | activeContexts.removeAll() 46 | } 47 | 48 | private func setupMemoryWarningObserver() { 49 | memoryWarningObserver = NotificationCenter.default.addObserver( 50 | forName: NSApplication.willTerminateNotification, 51 | object: nil, 52 | queue: .main 53 | ) { [weak self] _ in 54 | self?.handleMemoryWarning() 55 | } 56 | } 57 | 58 | private func handleMemoryWarning() { 59 | logger.warning("Memory warning received, cleaning up resources") 60 | cleanup() 61 | } 62 | 63 | deinit { 64 | if let observer = memoryWarningObserver { 65 | NotificationCenter.default.removeObserver(observer) 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /Convierto/Utilities/ResourceMonitor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class ResourceMonitor { 4 | private let minimumMemory: Int64 = 500_000_000 // 500MB 5 | private let minimumDiskSpace: Int64 = 1_000_000_000 // 1GB 6 | 7 | func hasAvailableMemory(required amount: Int64) -> Bool { 8 | let hostPort = mach_host_self() 9 | var hostSize = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) 10 | var hostInfo = vm_statistics64_data_t() 11 | 12 | let result = withUnsafeMutablePointer(to: &hostInfo) { 13 | $0.withMemoryRebound(to: integer_t.self, capacity: Int(hostSize)) { 14 | host_statistics64(hostPort, HOST_VM_INFO64, $0, &hostSize) 15 | } 16 | } 17 | 18 | if result == KERN_SUCCESS { 19 | let freeMemory = Int64(hostInfo.free_count) * Int64(vm_page_size) 20 | return freeMemory >= amount 21 | } 22 | 23 | return true // Default to true if we can't get memory info 24 | } 25 | 26 | var hasAvailableDiskSpace: Bool { 27 | guard let volumeURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { 28 | return true 29 | } 30 | 31 | do { 32 | let values = try volumeURL.resourceValues(forKeys: [.volumeAvailableCapacityKey]) 33 | if let capacity = values.volumeAvailableCapacity { 34 | return capacity >= minimumDiskSpace 35 | } 36 | } catch { 37 | // Log error but don't fail 38 | print("Error checking disk space: \(error)") 39 | } 40 | 41 | return true // Default to true if we can't get disk space info 42 | } 43 | 44 | func startMonitoring() -> MonitoringSession { 45 | return MonitoringSession() 46 | } 47 | } 48 | 49 | class MonitoringSession { 50 | private var timer: Timer? 51 | 52 | init() { 53 | startTimer() 54 | } 55 | 56 | private func startTimer() { 57 | timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in 58 | self?.checkResources() 59 | } 60 | } 61 | 62 | private func checkResources() { 63 | let pressure = ProcessInfo.processInfo.systemUptime 64 | if pressure > 0.8 { 65 | NotificationCenter.default.post(name: .memoryPressureWarning, object: nil) 66 | } 67 | } 68 | 69 | func stop() { 70 | timer?.invalidate() 71 | timer = nil 72 | } 73 | } -------------------------------------------------------------------------------- /Convierto/Utilities/ResourcePool.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Foundation 3 | 4 | actor ResourcePool { 5 | static let shared = ResourcePool() 6 | private var activeTasks: [UUID: TaskInfo] = [:] 7 | private let queue = OperationQueue() 8 | private var activeFiles: Set = [] 9 | private var temporaryFiles: Set = [] 10 | 11 | struct TaskInfo { 12 | let type: ResourceType 13 | let memoryUsage: UInt64 14 | let startTime: Date 15 | } 16 | 17 | init() { 18 | queue.maxConcurrentOperationCount = 2 19 | setupMemoryPressureHandling() 20 | } 21 | 22 | private nonisolated func setupMemoryPressureHandling() { 23 | Task { @MainActor in 24 | NotificationCenter.default.addObserver( 25 | forName: NSApplication.willTerminateNotification, 26 | object: nil, 27 | queue: .main 28 | ) { [weak self] _ in 29 | Task { [weak self] in 30 | await self?.cleanup(force: true) 31 | } 32 | } 33 | } 34 | } 35 | 36 | func beginTask(id: UUID, type: ResourceType) { 37 | activeTasks[id] = TaskInfo( 38 | type: type, 39 | memoryUsage: type.memoryRequirement, 40 | startTime: Date() 41 | ) 42 | } 43 | 44 | func endTask(id: UUID) { 45 | activeTasks.removeValue(forKey: id) 46 | GraphicsContextManager.shared.releaseContext(for: id.uuidString) 47 | } 48 | 49 | func checkResourceAvailability(taskId: UUID, type: ResourceType) async throws { 50 | try await checkResources(for: type) 51 | } 52 | 53 | func checkMemoryAvailability(required: UInt64) async throws { 54 | let availableMemory = ProcessInfo.processInfo.physicalMemory 55 | guard required < availableMemory * 7 / 10 else { 56 | throw ConversionError.insufficientMemory( 57 | required: required, 58 | available: availableMemory 59 | ) 60 | } 61 | } 62 | 63 | func canAllocateMemory(bytes: UInt64) -> Bool { 64 | let availableMemory = ProcessInfo.processInfo.physicalMemory 65 | let usedMemory = activeTasks.values.reduce(0) { $0 + $1.memoryUsage } 66 | return (usedMemory + bytes) < availableMemory * 7 / 10 67 | } 68 | 69 | func cleanup(force: Bool = false) async { 70 | for (id, task) in activeTasks { 71 | if force || Date().timeIntervalSince(task.startTime) > 3600 { // 1 hour timeout 72 | endTask(id: id) 73 | } 74 | } 75 | } 76 | 77 | private func checkResources(for type: ResourceType) async throws { 78 | let requiredMemory = type.memoryRequirement 79 | try await checkMemoryAvailability(required: requiredMemory) 80 | 81 | // Check if we have too many active tasks 82 | if activeTasks.count >= queue.maxConcurrentOperationCount { 83 | throw ConversionError.resourceExhausted(resource: "Maximum concurrent tasks reached") 84 | } 85 | } 86 | 87 | func getAvailableMemory() async -> UInt64 { 88 | let info = ProcessInfo.processInfo 89 | let totalMemory = info.physicalMemory 90 | let usedMemory = await getCurrentMemoryUsage() 91 | return totalMemory - usedMemory 92 | } 93 | 94 | private func getCurrentMemoryUsage() async -> UInt64 { 95 | var info = mach_task_basic_info() 96 | var count = mach_msg_type_number_t(MemoryLayout.size)/4 97 | 98 | let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { 99 | $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { 100 | task_info(mach_task_self_, 101 | task_flavor_t(MACH_TASK_BASIC_INFO), 102 | $0, 103 | &count) 104 | } 105 | } 106 | 107 | return kerr == KERN_SUCCESS ? UInt64(info.resident_size) : 0 108 | } 109 | 110 | func markFileAsActive(_ url: URL) { 111 | activeFiles.insert(url) 112 | } 113 | 114 | func markFileAsInactive(_ url: URL) { 115 | activeFiles.remove(url) 116 | } 117 | 118 | func addTemporaryFile(_ url: URL) { 119 | temporaryFiles.insert(url) 120 | } 121 | 122 | func removeTemporaryFile(_ url: URL) { 123 | temporaryFiles.remove(url) 124 | } 125 | 126 | func cleanup() { 127 | for url in temporaryFiles where !activeFiles.contains(url) { 128 | try? FileManager.default.removeItem(at: url) 129 | } 130 | temporaryFiles.removeAll() 131 | } 132 | 133 | func getMemoryUsage() -> UInt64 { 134 | var info = mach_task_basic_info() 135 | var count = mach_msg_type_number_t(MemoryLayout.size)/4 136 | 137 | let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { 138 | $0.withMemoryRebound(to: integer_t.self, capacity: 1) { 139 | task_info(mach_task_self_, 140 | task_flavor_t(MACH_TASK_BASIC_INFO), 141 | $0, 142 | &count) 143 | } 144 | } 145 | 146 | return kerr == KERN_SUCCESS ? info.resident_size : 0 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Convierto/Utilities/ResourceType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ResourceType { 4 | case conversion(ConversionStrategy) 5 | case document 6 | case image 7 | case video 8 | case audio 9 | 10 | var memoryRequirement: UInt64 { 11 | switch self { 12 | case .conversion(let strategy): 13 | switch strategy { 14 | case .direct: return 250_000_000 // 250MB 15 | case .extractFrame: return 500_000_000 // 500MB 16 | case .createVideo: return 750_000_000 // 750MB 17 | case .combine: return 1_000_000_000 // 1GB 18 | case .visualize: return 750_000_000 // 750MB 19 | case .extractAudio: return 250_000_000 // 250MB 20 | } 21 | case .document: return 500_000_000 // 500MB 22 | case .image: return 250_000_000 // 250MB 23 | case .video: return 750_000_000 // 750MB 24 | case .audio: return 100_000_000 // 100MB 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Convierto/Utilities/SendableItemProviderWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | 4 | @MainActor 5 | class SendableItemProviderWrapper { 6 | private let provider: NSItemProvider 7 | 8 | init(_ provider: NSItemProvider) { 9 | self.provider = provider 10 | } 11 | 12 | var canLoadObject: Bool { 13 | provider.canLoadObject(ofClass: URL.self) 14 | } 15 | 16 | func loadItem(forTypeIdentifier: String) async throws -> URL? { 17 | try await withCheckedThrowingContinuation { continuation in 18 | provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { item, error in 19 | if let error = error { 20 | continuation.resume(throwing: error) 21 | } else if let url = item as? URL { 22 | continuation.resume(returning: url) 23 | } else { 24 | continuation.resume(returning: nil) 25 | } 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Convierto/Utilities/SendableWrapper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @MainActor 4 | final class SendableWrapper { 5 | private let provider: NSItemProvider 6 | 7 | init(_ provider: NSItemProvider) { 8 | self.provider = provider 9 | } 10 | 11 | func loadItem(forTypeIdentifier typeIdentifier: String) async throws -> URL? { 12 | try await withCheckedThrowingContinuation { continuation in 13 | provider.loadItem(forTypeIdentifier: typeIdentifier) { (item, error) in 14 | if let error = error { 15 | continuation.resume(throwing: error) 16 | } else if let url = item as? URL { 17 | continuation.resume(returning: url) 18 | } else { 19 | continuation.resume(returning: nil) 20 | } 21 | } 22 | } 23 | } 24 | 25 | var canLoadObject: Bool { 26 | provider.canLoadObject(ofClass: URL.self) 27 | } 28 | } -------------------------------------------------------------------------------- /Convierto/Utilities/WaveformStyle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | 4 | enum WaveformStyle { 5 | case line 6 | case bars 7 | case filled 8 | case dots 9 | 10 | var lineWidth: CGFloat { 11 | switch self { 12 | case .line: 13 | return 1.0 14 | case .bars: 15 | return 2.0 16 | case .filled: 17 | return 3.0 18 | case .dots: 19 | return 4.0 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /Convierto/Views/Components/CommandPalette.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct CommandPalette: View { 4 | @Binding var isPresented: Bool 5 | @ViewBuilder let content: () -> Content 6 | @State private var opacity: Double = 0 7 | @State private var scale: CGFloat = 0.95 8 | 9 | var body: some View { 10 | GeometryReader { geometry in 11 | if isPresented { 12 | ZStack { 13 | // Backdrop blur and overlay 14 | Color.black 15 | .opacity(0.2 * opacity) 16 | .ignoresSafeArea() 17 | .onTapGesture { 18 | dismiss() 19 | } 20 | 21 | // Content container 22 | VStack(spacing: 0) { 23 | content() 24 | } 25 | .frame(width: min(geometry.size.width - 40, 400)) 26 | .background( 27 | RoundedRectangle(cornerRadius: 12) 28 | .fill(Color(NSColor.windowBackgroundColor).opacity(0.98)) 29 | ) 30 | .clipShape(RoundedRectangle(cornerRadius: 12)) 31 | .overlay( 32 | RoundedRectangle(cornerRadius: 12) 33 | .stroke(Color.primary.opacity(0.1), lineWidth: 1) 34 | ) 35 | .shadow(color: Color.black.opacity(0.2), radius: 20, x: 0, y: 10) 36 | .padding(.top, geometry.size.height * 0.1) 37 | .scaleEffect(scale) 38 | .opacity(opacity) 39 | } 40 | .frame(maxWidth: .infinity, maxHeight: .infinity) 41 | .transition(.opacity) 42 | .onAppear { 43 | withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { 44 | opacity = 1 45 | scale = 1 46 | } 47 | } 48 | } 49 | } 50 | } 51 | 52 | private func dismiss() { 53 | withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { 54 | opacity = 0 55 | scale = 0.95 56 | } 57 | 58 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 59 | isPresented = false 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /Convierto/Views/Components/FormatButton.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UniformTypeIdentifiers 3 | 4 | struct FormatButton: View { 5 | let format: UTType 6 | let isSelected: Bool 7 | let action: () -> Void 8 | 9 | var body: some View { 10 | Button(action: action) { 11 | HStack(spacing: 12) { 12 | Image(systemName: FormatSelectorMenu.getFormatIcon(for: format)) 13 | .font(.system(size: 14)) 14 | .foregroundColor(isSelected ? .accentColor : .secondary) 15 | 16 | VStack(alignment: .leading, spacing: 2) { 17 | Text(format.preferredFilenameExtension?.uppercased() ?? format.identifier) 18 | .font(.system(size: 14, weight: .medium)) 19 | 20 | Text(FormatSelectorMenu.getFormatDescription(for: format)) 21 | .font(.system(size: 12)) 22 | .foregroundColor(.secondary) 23 | } 24 | 25 | Spacer() 26 | 27 | if isSelected { 28 | Image(systemName: "checkmark") 29 | .font(.system(size: 12, weight: .bold)) 30 | .foregroundColor(.accentColor) 31 | } 32 | } 33 | .contentShape(Rectangle()) 34 | } 35 | .buttonStyle(.plain) 36 | .padding(.vertical, 8) 37 | .padding(.horizontal, 12) 38 | .background( 39 | RoundedRectangle(cornerRadius: 8) 40 | .fill(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) 41 | ) 42 | } 43 | } -------------------------------------------------------------------------------- /Convierto/Views/DropZoneView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UniformTypeIdentifiers 3 | import os.log 4 | 5 | private let logger = Logger( 6 | subsystem: Bundle.main.bundleIdentifier ?? "Convierto", 7 | category: "DropZone" 8 | ) 9 | 10 | struct DropZoneView: View { 11 | @Binding var isDragging: Bool 12 | @Binding var showError: Bool 13 | @Binding var errorMessage: String? 14 | let selectedFormat: UTType 15 | let onFilesSelected: ([URL]) -> Void 16 | 17 | private let dropDelegate: FileDropDelegate 18 | 19 | init(isDragging: Binding, showError: Binding, errorMessage: Binding, selectedFormat: UTType, onFilesSelected: @escaping ([URL]) -> Void) { 20 | self._isDragging = isDragging 21 | self._showError = showError 22 | self._errorMessage = errorMessage 23 | self.selectedFormat = selectedFormat 24 | self.onFilesSelected = onFilesSelected 25 | 26 | self.dropDelegate = FileDropDelegate( 27 | isDragging: isDragging, 28 | supportedTypes: [.fileURL], 29 | handleDrop: { providers in 30 | Task { 31 | do { 32 | let handler = FileDropHandler() 33 | let urls = try await handler.handleProviders(providers, outputFormat: selectedFormat) 34 | onFilesSelected(urls) 35 | 36 | await MainActor.run { 37 | withAnimation { 38 | showError.wrappedValue = false 39 | errorMessage.wrappedValue = nil 40 | } 41 | } 42 | } catch { 43 | logger.error("Drop handling failed: \(error.localizedDescription)") 44 | await MainActor.run { 45 | withAnimation { 46 | errorMessage.wrappedValue = error.localizedDescription 47 | showError.wrappedValue = true 48 | } 49 | } 50 | 51 | // Auto-hide error after 3 seconds 52 | try? await Task.sleep(for: .seconds(3)) 53 | await MainActor.run { 54 | withAnimation { 55 | showError.wrappedValue = false 56 | errorMessage.wrappedValue = nil 57 | } 58 | } 59 | } 60 | } 61 | } 62 | ) 63 | } 64 | 65 | var body: some View { 66 | ZStack { 67 | RoundedRectangle(cornerRadius: 24) 68 | .fill(Color(NSColor.controlBackgroundColor).opacity(0.4)) 69 | .overlay( 70 | RoundedRectangle(cornerRadius: 24) 71 | .strokeBorder( 72 | LinearGradient( 73 | colors: showError ? 74 | [.red.opacity(0.3), .red.opacity(0.2)] : 75 | isDragging ? 76 | [.accentColor.opacity(0.3), .accentColor.opacity(0.2)] : 77 | [.secondary.opacity(0.1), .secondary.opacity(0.05)], 78 | startPoint: .top, 79 | endPoint: .bottom 80 | ), 81 | lineWidth: isDragging || showError ? 2 : 1 82 | ) 83 | ) 84 | 85 | VStack(spacing: 16) { 86 | // Drop zone content 87 | DropZoneContent( 88 | isDragging: isDragging, 89 | showError: showError, 90 | errorMessage: errorMessage, 91 | onTryAgain: { 92 | withAnimation { 93 | showError = false 94 | errorMessage = nil 95 | isDragging = false 96 | } 97 | }, 98 | onStartOver: { 99 | withAnimation { 100 | showError = false 101 | errorMessage = nil 102 | isDragging = false 103 | onFilesSelected([]) // Clear any selected files 104 | } 105 | } 106 | ) 107 | } 108 | .padding(40) 109 | } 110 | .frame(maxWidth: .infinity, maxHeight: .infinity) 111 | .contentShape(Rectangle()) 112 | .onTapGesture(perform: selectFiles) 113 | .onDrop(of: [.fileURL], delegate: dropDelegate) 114 | } 115 | 116 | private func selectFiles() { 117 | let panel = NSOpenPanel() 118 | panel.allowsMultipleSelection = true 119 | panel.canChooseDirectories = false 120 | panel.canCreateDirectories = false 121 | panel.canChooseFiles = true 122 | panel.allowedContentTypes = [.image, .movie, .audio, .pdf] 123 | 124 | Task { @MainActor in 125 | guard let window = NSApp.windows.first else { return } 126 | let response = await panel.beginSheetModal(for: window) 127 | 128 | if response == .OK { 129 | onFilesSelected(panel.urls) 130 | } 131 | } 132 | } 133 | } 134 | 135 | private struct DropZoneContent: View { 136 | let isDragging: Bool 137 | let showError: Bool 138 | let errorMessage: String? 139 | let onTryAgain: () -> Void 140 | let onStartOver: () -> Void 141 | 142 | var body: some View { 143 | VStack(spacing: 16) { 144 | ZStack { 145 | Circle() 146 | .fill(showError ? Color.red.opacity(0.1) : Color.accentColor.opacity(0.1)) 147 | .frame(width: 64, height: 64) 148 | 149 | Image(systemName: showError ? "exclamationmark.circle.fill" : 150 | isDragging ? "arrow.down.circle.fill" : "square.and.arrow.up.circle.fill") 151 | .font(.system(size: 32, weight: .medium)) 152 | .foregroundStyle( 153 | LinearGradient( 154 | colors: showError ? [.red, .red.opacity(0.8)] : 155 | [.accentColor, .accentColor.opacity(0.8)], 156 | startPoint: .top, 157 | endPoint: .bottom 158 | ) 159 | ) 160 | .symbolEffect(.bounce, value: isDragging) 161 | } 162 | 163 | VStack(spacing: 8) { 164 | Text(showError ? (errorMessage ?? "Error") : 165 | isDragging ? "Release to Convert" : "Drop Files Here") 166 | .font(.system(size: 16, weight: .medium)) 167 | 168 | if showError { 169 | Text("Try dropping the file again or choose another") 170 | .font(.system(size: 14)) 171 | .foregroundColor(.secondary) 172 | .multilineTextAlignment(.center) 173 | .padding(.horizontal, 32) 174 | } else if !isDragging { 175 | Text("or click to browse") 176 | .font(.system(size: 14)) 177 | .foregroundColor(.secondary) 178 | } 179 | } 180 | 181 | if showError { 182 | HStack(spacing: 12) { 183 | Button(action: onStartOver) { 184 | Text("Start Over") 185 | .font(.system(size: 14, weight: .medium)) 186 | .foregroundColor(.secondary) 187 | } 188 | .buttonStyle(.plain) 189 | .padding(.horizontal, 16) 190 | .padding(.vertical, 8) 191 | .background( 192 | RoundedRectangle(cornerRadius: 8) 193 | .stroke(Color.secondary.opacity(0.2), lineWidth: 1) 194 | ) 195 | 196 | Button(action: onTryAgain) { 197 | Text("Try Again") 198 | .font(.system(size: 14, weight: .medium)) 199 | .foregroundColor(.white) 200 | } 201 | .buttonStyle(.plain) 202 | .padding(.horizontal, 16) 203 | .padding(.vertical, 8) 204 | .background( 205 | RoundedRectangle(cornerRadius: 8) 206 | .fill( 207 | LinearGradient( 208 | colors: [.red, .red.opacity(0.8)], 209 | startPoint: .top, 210 | endPoint: .bottom 211 | ) 212 | ) 213 | ) 214 | } 215 | } 216 | 217 | if !isDragging && !showError { 218 | HStack(spacing: 4) { 219 | Text("Press") 220 | .font(.system(size: 11)) 221 | .foregroundColor(.secondary.opacity(0.7)) 222 | 223 | HStack(spacing: 2) { 224 | Text("⌘") 225 | .font(.system(size: 11, weight: .medium)) 226 | Text("K") 227 | .font(.system(size: 11, weight: .medium)) 228 | } 229 | .padding(.horizontal, 4) 230 | .padding(.vertical, 2) 231 | .background( 232 | RoundedRectangle(cornerRadius: 4) 233 | .fill(Color.secondary.opacity(0.08)) 234 | .overlay( 235 | RoundedRectangle(cornerRadius: 4) 236 | .stroke(Color.secondary.opacity(0.1), lineWidth: 0.5) 237 | ) 238 | ) 239 | 240 | Text("to browse formats") 241 | .font(.system(size: 11)) 242 | .foregroundColor(.secondary.opacity(0.7)) 243 | } 244 | .padding(.top, 8) 245 | .opacity(0.8) 246 | .transition(.opacity) 247 | } 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /Convierto/Views/FormatSelectorMenu.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UniformTypeIdentifiers 3 | 4 | struct FormatSelectorMenu: View { 5 | @Binding var selectedFormat: UTType 6 | let supportedTypes: [String: [UTType]] 7 | @Binding var isPresented: Bool 8 | @Environment(\.colorScheme) var colorScheme 9 | @State private var searchText: String = "" 10 | @FocusState private var isSearchFocused: Bool 11 | 12 | private var filteredTypes: [String: [UTType]] { 13 | if searchText.isEmpty { return supportedTypes } 14 | 15 | return supportedTypes.mapValues { formats in 16 | formats.filter { format in 17 | let description = Self.getFormatDescription(for: format).lowercased() 18 | let identifier = format.identifier.lowercased() 19 | let fileExtension = format.preferredFilenameExtension?.lowercased() ?? "" 20 | let searchQuery = searchText.lowercased() 21 | 22 | return description.contains(searchQuery) || 23 | identifier.contains(searchQuery) || 24 | fileExtension.contains(searchQuery) 25 | } 26 | }.filter { !$0.value.isEmpty } 27 | } 28 | 29 | var body: some View { 30 | CommandPalette(isPresented: $isPresented) { 31 | VStack(spacing: 0) { 32 | // Search field 33 | HStack(spacing: 12) { 34 | Image(systemName: "magnifyingglass") 35 | .font(.system(size: 14)) 36 | .foregroundColor(.secondary) 37 | 38 | TextField("Search formats...", text: $searchText) 39 | .textFieldStyle(.plain) 40 | .font(.system(size: 14)) 41 | .focused($isSearchFocused) 42 | 43 | if !searchText.isEmpty { 44 | Button(action: { searchText = "" }) { 45 | Image(systemName: "xmark.circle.fill") 46 | .foregroundColor(.secondary) 47 | .font(.system(size: 14)) 48 | } 49 | .buttonStyle(.plain) 50 | } 51 | } 52 | .padding(.horizontal, 16) 53 | .padding(.vertical, 12) 54 | .background(Color(NSColor.controlBackgroundColor).opacity(0.5)) 55 | 56 | Divider() 57 | 58 | ScrollView(.vertical, showsIndicators: false) { 59 | VStack(alignment: .leading, spacing: 20) { 60 | ForEach(Array(filteredTypes.keys.sorted()), id: \.self) { category in 61 | if let formats = filteredTypes[category], !formats.isEmpty { 62 | VStack(alignment: .leading, spacing: 12) { 63 | Text(category) 64 | .font(.system(size: 12, weight: .medium)) 65 | .foregroundColor(.secondary) 66 | .padding(.horizontal, 16) 67 | 68 | ForEach(formats, id: \.identifier) { format in 69 | FormatButton( 70 | format: format, 71 | isSelected: format == selectedFormat 72 | ) { 73 | withAnimation(.spring(response: 0.3)) { 74 | selectedFormat = format 75 | isPresented = false 76 | } 77 | } 78 | .padding(.horizontal, 16) 79 | } 80 | } 81 | } 82 | } 83 | } 84 | .padding(.vertical, 16) 85 | } 86 | } 87 | } 88 | .onAppear { 89 | isSearchFocused = true 90 | } 91 | } 92 | 93 | static func getFormatDescription(for format: UTType) -> String { 94 | switch format { 95 | case .jpeg: 96 | return "Compressed image format" 97 | case .png: 98 | return "Lossless image format" 99 | case .heic: 100 | return "High-efficiency format" 101 | case .gif: 102 | return "Animated image format" 103 | case .pdf: 104 | return "Document format" 105 | case .mp3: 106 | return "Compressed audio" 107 | case .wav: 108 | return "Lossless audio" 109 | case .mpeg4Movie: 110 | return "High-quality video" 111 | case .webP: 112 | return "Web-optimized format" 113 | case .aiff: 114 | return "High-quality audio" 115 | case .m4a: 116 | return "AAC audio format" 117 | case .avi: 118 | return "Video format" 119 | case .raw: 120 | return "Camera RAW format" 121 | case .tiff: 122 | return "Professional image format" 123 | default: 124 | return format.preferredFilenameExtension?.uppercased() ?? 125 | format.identifier.components(separatedBy: ".").last?.uppercased() ?? 126 | "Unknown format" 127 | } 128 | } 129 | 130 | static func getFormatIcon(for format: UTType) -> String { 131 | if format.isImageFormat { 132 | switch format { 133 | case .heic: 134 | return "photo.fill" 135 | case .raw: 136 | return "camera.aperture" 137 | case .gif: 138 | return "square.stack.3d.down.right.fill" 139 | default: 140 | return "photo" 141 | } 142 | } else if format.isVideoFormat { 143 | return "film.fill" 144 | } else if format.isAudioFormat { 145 | switch format { 146 | case .mp3: 147 | return "waveform" 148 | case .wav: 149 | return "waveform.circle.fill" 150 | case .aiff: 151 | return "waveform.badge.plus" 152 | default: 153 | return "music.note" 154 | } 155 | } else if format.isPDFFormat { 156 | return "doc.fill" 157 | } else { 158 | return "doc.circle.fill" 159 | } 160 | } 161 | } 162 | 163 | struct VisualEffectView: NSViewRepresentable { 164 | let material: NSVisualEffectView.Material 165 | let blendingMode: NSVisualEffectView.BlendingMode 166 | 167 | func makeNSView(context: Context) -> NSVisualEffectView { 168 | let visualEffectView = NSVisualEffectView() 169 | visualEffectView.material = material 170 | visualEffectView.blendingMode = blendingMode 171 | visualEffectView.state = .active 172 | return visualEffectView 173 | } 174 | 175 | func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) { 176 | visualEffectView.material = material 177 | visualEffectView.blendingMode = blendingMode 178 | } 179 | } -------------------------------------------------------------------------------- /Convierto/Views/MenuBarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct MenuBarView: View { 5 | @ObservedObject var updater: UpdateChecker 6 | @Environment(\.dismiss) var dismiss 7 | 8 | private var appIcon: NSImage { 9 | if let bundleIcon = NSImage(named: NSImage.applicationIconName) { 10 | return bundleIcon 11 | } 12 | return NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) 13 | } 14 | 15 | var body: some View { 16 | VStack(spacing: 16) { 17 | // App Icon and Version 18 | VStack(spacing: 8) { 19 | Image(nsImage: appIcon) 20 | .resizable() 21 | .aspectRatio(contentMode: .fit) 22 | .frame(width: 64, height: 64) 23 | 24 | Text("Version \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0")") 25 | .font(.subheadline) 26 | .foregroundColor(.secondary) 27 | } 28 | .padding(.top, 16) 29 | 30 | // Status Section 31 | Group { 32 | if updater.isChecking { 33 | VStack(spacing: 8) { 34 | ProgressView() 35 | .scaleEffect(1.2) 36 | Text("Checking for updates...") 37 | .font(.headline) 38 | .foregroundColor(.secondary) 39 | } 40 | } else if let error = updater.error { 41 | VStack(spacing: 8) { 42 | Image(systemName: "xmark.circle.fill") 43 | .font(.system(size: 28)) 44 | .foregroundColor(.red) 45 | Text(error) 46 | .font(.subheadline) 47 | .foregroundColor(.secondary) 48 | .multilineTextAlignment(.center) 49 | } 50 | } else if updater.updateAvailable { 51 | VStack(spacing: 12) { 52 | Image(systemName: "arrow.down.circle.fill") 53 | .font(.system(size: 28)) 54 | .foregroundColor(.blue) 55 | 56 | if let version = updater.latestVersion { 57 | Text("Version \(version) Available") 58 | .font(.headline) 59 | } 60 | 61 | if let notes = updater.releaseNotes { 62 | ScrollView { 63 | Text(notes) 64 | .font(.footnote) 65 | .foregroundColor(.secondary) 66 | .multilineTextAlignment(.center) 67 | .padding(.horizontal) 68 | } 69 | .frame(maxHeight: 80) 70 | } 71 | 72 | Button { 73 | if let url = updater.downloadURL { 74 | NSWorkspace.shared.open(url) 75 | dismiss() 76 | } 77 | } label: { 78 | Text("Download Update") 79 | .frame(maxWidth: 200) 80 | } 81 | .buttonStyle(.borderedProminent) 82 | } 83 | } else { 84 | VStack(spacing: 8) { 85 | Image(systemName: "checkmark.circle.fill") 86 | .font(.system(size: 28)) 87 | .foregroundColor(.green) 88 | Text("Convierto is up to date") 89 | .font(.headline) 90 | } 91 | } 92 | } 93 | .frame(maxWidth: .infinity, alignment: .center) 94 | .padding(.vertical, 8) 95 | 96 | Divider() 97 | 98 | // Bottom Buttons 99 | HStack(spacing: 16) { 100 | Button("Check Again") { 101 | updater.checkForUpdates() 102 | } 103 | .buttonStyle(.plain) 104 | .foregroundColor(.blue) 105 | 106 | Button("Close") { 107 | dismiss() 108 | } 109 | .buttonStyle(.plain) 110 | .foregroundColor(.secondary) 111 | } 112 | .padding(.bottom, 16) 113 | 114 | Text("Built by [Nuance](https://nuanc.me)") 115 | .font(.footnote) 116 | .foregroundColor(.secondary) 117 | .padding(.bottom, 8) 118 | } 119 | .padding(.horizontal) 120 | .frame(width: 300) 121 | .fixedSize(horizontal: false, vertical: true) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Convierto/Views/MultiFileProcessingView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UniformTypeIdentifiers 3 | import SwiftUI 4 | import os.log 5 | 6 | private let logger = Logger( 7 | subsystem: Bundle.main.bundleIdentifier ?? "Convierto", 8 | category: "MultiFileProcessingView" 9 | ) 10 | 11 | struct FileProcessingState: Identifiable { 12 | let id: UUID 13 | let url: URL 14 | let originalFileName: String 15 | var progress: Double 16 | var result: ProcessingResult? 17 | var isProcessing: Bool 18 | var error: Error? 19 | var stage: ConversionStage = .idle 20 | 21 | // Add this computed property 22 | var displayFileName: String { 23 | // If the filename contains UUID prefix, remove it 24 | let filename = url.lastPathComponent 25 | if let range = filename.range(of: "_") { 26 | return String(filename[range.upperBound...]) 27 | } 28 | return originalFileName 29 | } 30 | 31 | init(url: URL) { 32 | self.id = UUID() 33 | self.url = url 34 | self.originalFileName = url.lastPathComponent 35 | self.progress = 0 36 | self.result = nil 37 | self.isProcessing = false 38 | self.error = nil 39 | self.stage = .idle 40 | } 41 | } 42 | 43 | @MainActor 44 | class MultiFileProcessor: ObservableObject { 45 | @Published private(set) var files: [FileProcessingState] = [] 46 | @Published private(set) var isProcessingMultiple = false 47 | @Published var selectedOutputFormat: UTType = .jpeg 48 | @Published var progress: Double = 0 49 | @Published var isProcessing: Bool = false 50 | @Published var processingResult: ProcessingResult? 51 | @Published var error: ConversionError? 52 | @Published var conversionResult: ProcessingResult? 53 | private var processingTasks: [UUID: Task] = [:] 54 | private var currentTask: Task? 55 | 56 | func addFiles(_ urls: [URL]) { 57 | let newFiles = urls.map { FileProcessingState(url: $0) } 58 | files.append(contentsOf: newFiles) 59 | 60 | // Process each new file individually 61 | for file in newFiles { 62 | processFile(with: file.id) 63 | } 64 | } 65 | 66 | func removeFile(at index: Int) { 67 | guard index < files.count else { return } 68 | let fileId = files[index].id 69 | processingTasks[fileId]?.cancel() 70 | processingTasks.removeValue(forKey: fileId) 71 | files.remove(at: index) 72 | } 73 | 74 | func clearFiles(completion: (() -> Void)? = nil) { 75 | // Cancel all ongoing processing tasks 76 | for task in processingTasks.values { 77 | task.cancel() 78 | } 79 | processingTasks.removeAll() 80 | files.removeAll() 81 | completion?() 82 | } 83 | 84 | private func processFile(with id: UUID) { 85 | let task = Task { 86 | await processFileInternal(with: id) 87 | } 88 | processingTasks[id] = task 89 | currentTask = task 90 | } 91 | 92 | func saveAllFilesToFolder() async throws { 93 | return try await withCheckedThrowingContinuation { continuation in 94 | Task { 95 | do { 96 | let panel = NSOpenPanel() 97 | panel.canCreateDirectories = true 98 | panel.canChooseFiles = false 99 | panel.canChooseDirectories = true 100 | panel.message = "Choose where to save all converted files" 101 | panel.prompt = "Select Folder" 102 | 103 | guard let window = NSApp.windows.first else { 104 | throw ConversionError.conversionFailed(reason: "No window available") 105 | } 106 | 107 | let response = await panel.beginSheetModal(for: window) 108 | 109 | if response == .OK, let folderURL = panel.url { 110 | for file in files { 111 | if let result = file.result { 112 | do { 113 | let destinationURL = folderURL.appendingPathComponent(result.suggestedFileName) 114 | try FileManager.default.copyItem(at: result.outputURL, to: destinationURL) 115 | } catch { 116 | throw ConversionError.exportFailed(reason: "Failed to save file: \(error.localizedDescription)") 117 | } 118 | } 119 | } 120 | continuation.resume() 121 | } else { 122 | continuation.resume(throwing: ConversionError.cancelled) 123 | } 124 | } catch { 125 | continuation.resume(throwing: error) 126 | } 127 | } 128 | } 129 | } 130 | 131 | @MainActor 132 | private func processFileInternal(with id: UUID) async { 133 | guard let fileState = files.first(where: { $0.id == id }) else { return } 134 | guard let index = files.firstIndex(where: { $0.id == id }) else { return } 135 | 136 | files[index].isProcessing = true 137 | files[index].progress = 0 138 | files[index].stage = .analyzing 139 | isProcessing = true 140 | 141 | do { 142 | let processor = FileProcessor() 143 | 144 | let progressObserver = processor.$conversionProgress 145 | .sink { [weak self] progress in 146 | guard let self = self else { return } 147 | self.files[index].progress = progress 148 | 149 | // Update stage based on progress 150 | if progress < 0.2 { 151 | self.files[index].stage = .analyzing 152 | } else if progress < 0.8 { 153 | self.files[index].stage = .converting 154 | } else if progress < 1.0 { 155 | self.files[index].stage = .optimizing 156 | } 157 | 158 | self.updateOverallProgress() 159 | } 160 | 161 | let result = try await processor.processFile(fileState.url, outputFormat: selectedOutputFormat) 162 | 163 | if !Task.isCancelled { 164 | files[index].result = result 165 | files[index].progress = 1.0 166 | files[index].stage = .completed 167 | } 168 | 169 | progressObserver.cancel() 170 | } catch { 171 | files[index].error = error 172 | files[index].stage = .failed 173 | } 174 | 175 | files[index].isProcessing = false 176 | isProcessing = files.contains(where: { $0.isProcessing }) 177 | updateOverallProgress() 178 | } 179 | 180 | private func updateOverallProgress() { 181 | let completedCount = Double(files.filter { $0.result != nil }.count) 182 | let totalCount = Double(files.count) 183 | updateProgress(totalCount > 0 ? completedCount / totalCount : 0) 184 | } 185 | 186 | func saveConvertedFile(url: URL, originalName: String) async throws { 187 | logger.debug("💾 Starting save process") 188 | logger.debug("📂 Source URL: \(url.path)") 189 | logger.debug("📝 Original name: \(originalName)") 190 | 191 | // Verify source file exists 192 | guard FileManager.default.fileExists(atPath: url.path) else { 193 | logger.error("❌ Source file does not exist at path: \(url.path)") 194 | throw ConversionError.fileAccessDenied(path: url.path) 195 | } 196 | 197 | let panel = NSSavePanel() 198 | panel.canCreateDirectories = true 199 | panel.showsTagField = false 200 | 201 | // Get the extension from the source URL 202 | let sourceExtension = url.pathExtension 203 | logger.debug("📎 Source extension: \(sourceExtension)") 204 | 205 | // Clean up the original filename 206 | let filenameWithoutExt = (originalName as NSString).deletingPathExtension 207 | let suggestedFilename = "\(filenameWithoutExt)_converted.\(sourceExtension)" 208 | 209 | panel.nameFieldStringValue = suggestedFilename 210 | panel.message = "Choose where to save the converted file" 211 | 212 | // Use the actual file's UTType 213 | if let fileType = try? UTType(filenameExtension: sourceExtension) { 214 | panel.allowedContentTypes = [fileType] 215 | logger.debug("🎯 Setting allowed content type: \(fileType.identifier)") 216 | } 217 | 218 | guard let window = NSApp.windows.first else { 219 | logger.error("❌ No window found for save panel") 220 | throw ConversionError.conversionFailed(reason: "No window available") 221 | } 222 | 223 | let response = await panel.beginSheetModal(for: window) 224 | 225 | if response == .OK, let saveURL = panel.url { 226 | logger.debug("✅ Save location selected: \(saveURL.path)") 227 | 228 | do { 229 | try FileManager.default.copyItem(at: url, to: saveURL) 230 | logger.debug("✅ File saved successfully") 231 | } catch { 232 | logger.error("❌ Failed to save file: \(error.localizedDescription)") 233 | throw ConversionError.exportFailed(reason: error.localizedDescription) 234 | } 235 | } 236 | } 237 | 238 | func downloadAllFiles() async throws { 239 | for file in files { 240 | if let result = file.result { 241 | do { 242 | try await saveConvertedFile(url: result.outputURL, originalName: file.originalFileName) 243 | } catch { 244 | logger.error("❌ Failed to save file \(file.originalFileName): \(error.localizedDescription)") 245 | // Throw the error to propagate it up 246 | throw ConversionError.exportFailed(reason: "Failed to save file: \(error.localizedDescription)") 247 | } 248 | } 249 | } 250 | } 251 | 252 | func cancelProcessing() { 253 | currentTask?.cancel() 254 | isProcessing = false 255 | processingResult = nil 256 | 257 | // Cancel all individual file processing tasks 258 | for task in processingTasks.values { 259 | task.cancel() 260 | } 261 | processingTasks.removeAll() 262 | 263 | // Reset progress 264 | progress = 0 265 | 266 | // Update file states 267 | for (index, _) in files.enumerated() where files[index].isProcessing { 268 | files[index].isProcessing = false 269 | files[index].error = ConversionError.cancelled 270 | } 271 | } 272 | 273 | // Add a public method to update progress 274 | func updateProgress(_ newProgress: Double) { 275 | progress = newProgress 276 | } 277 | 278 | @MainActor 279 | func cleanup() { 280 | Task { @MainActor in 281 | // Cancel any ongoing processing 282 | cancelProcessing() 283 | 284 | // Clear all files and results 285 | clearFiles() 286 | 287 | // Reset state 288 | isProcessing = false 289 | processingResult = nil 290 | progress = 0 291 | error = nil 292 | conversionResult = nil 293 | } 294 | } 295 | 296 | func processFile(_ url: URL, outputFormat: UTType) async throws -> ProcessingResult { 297 | let fileState = FileProcessingState(url: url) 298 | files.append(fileState) 299 | 300 | guard let index = files.firstIndex(where: { $0.id == fileState.id }) else { 301 | throw ConversionError.conversionFailed(reason: "Failed to track file state") 302 | } 303 | 304 | files[index].isProcessing = true 305 | files[index].progress = 0 306 | files[index].stage = .analyzing 307 | isProcessing = true 308 | 309 | do { 310 | let processor = FileProcessor() 311 | 312 | let progressObserver = processor.$conversionProgress 313 | .sink { [weak self] progress in 314 | guard let self = self else { return } 315 | self.files[index].progress = progress 316 | 317 | // Update stage based on progress 318 | if progress < 0.2 { 319 | self.files[index].stage = .analyzing 320 | } else if progress < 0.8 { 321 | self.files[index].stage = .converting 322 | } else if progress < 1.0 { 323 | self.files[index].stage = .optimizing 324 | } 325 | 326 | self.updateOverallProgress() 327 | } 328 | 329 | let result = try await processor.processFile(url, outputFormat: outputFormat) 330 | 331 | if !Task.isCancelled { 332 | files[index].result = result 333 | files[index].progress = 1.0 334 | files[index].stage = .completed 335 | } 336 | 337 | progressObserver.cancel() 338 | return result 339 | 340 | } catch { 341 | files[index].error = error 342 | files[index].stage = .failed 343 | throw error 344 | } 345 | } 346 | } 347 | 348 | struct MultiFileView: View { 349 | @ObservedObject var processor: MultiFileProcessor 350 | let supportedTypes: [UTType] 351 | @State private var hoveredFileId: UUID? 352 | let onReset: () -> Void 353 | 354 | var body: some View { 355 | VStack(spacing: 24) { 356 | // Header with actions 357 | HStack { 358 | Text("Files to Convert") 359 | .font(.system(size: 16, weight: .semibold)) 360 | 361 | Spacer() 362 | 363 | Button(action: { 364 | withAnimation(.spring(response: 0.3)) { 365 | processor.clearFiles { 366 | onReset() 367 | } 368 | } 369 | }) { 370 | Text("Clear All") 371 | .font(.system(size: 13)) 372 | .foregroundColor(.secondary) 373 | } 374 | .buttonStyle(.plain) 375 | } 376 | 377 | // File list 378 | ScrollView { 379 | LazyVStack(spacing: 12) { 380 | ForEach(processor.files) { file in 381 | FileItemView( 382 | file: file, 383 | targetFormat: processor.selectedOutputFormat, 384 | isHovered: hoveredFileId == file.id, 385 | onRemove: { 386 | if let index = processor.files.firstIndex(where: { $0.id == file.id }) { 387 | processor.removeFile(at: index) 388 | } 389 | }, 390 | processor: processor 391 | ) 392 | .onHover { isHovered in 393 | hoveredFileId = isHovered ? file.id : nil 394 | } 395 | } 396 | } 397 | .padding(.vertical, 8) 398 | } 399 | .frame(maxWidth: .infinity, maxHeight: .infinity) 400 | .background( 401 | RoundedRectangle(cornerRadius: 16) 402 | .fill(Color(NSColor.controlBackgroundColor).opacity(0.5)) 403 | ) 404 | 405 | // Bottom actions 406 | HStack(spacing: 16) { 407 | Button(action: { 408 | Task { 409 | do { 410 | try await processor.saveAllFilesToFolder() 411 | } catch { 412 | logger.error("❌ Failed to save files: \(error.localizedDescription)") 413 | // Here you might want to show an error alert to the user 414 | } 415 | } 416 | }) { 417 | HStack(spacing: 8) { 418 | Image(systemName: "square.and.arrow.down") 419 | Text("Save All") 420 | } 421 | .font(.system(size: 14, weight: .medium)) 422 | } 423 | .buttonStyle(.plain) 424 | .disabled(processor.files.allSatisfy { $0.result == nil }) 425 | 426 | Spacer() 427 | 428 | // Format selector 429 | Menu { 430 | ForEach(supportedTypes, id: \.identifier) { format in 431 | Button(action: { processor.selectedOutputFormat = format }) { 432 | HStack { 433 | Text(format.localizedDescription ?? "Unknown format") 434 | if format == processor.selectedOutputFormat { 435 | Image(systemName: "checkmark") 436 | } 437 | } 438 | } 439 | } 440 | } label: { 441 | HStack(spacing: 4) { 442 | Text("Convert to: \(String(describing: processor.selectedOutputFormat.localizedDescription))") 443 | .font(.system(size: 13)) 444 | Image(systemName: "chevron.down") 445 | .font(.system(size: 10)) 446 | } 447 | .foregroundColor(.secondary) 448 | } 449 | .menuStyle(.borderlessButton) 450 | 451 | Button(action: { 452 | Task { 453 | do { 454 | try await processor.downloadAllFiles() 455 | } catch { 456 | logger.error("❌ Failed to download files: \(error.localizedDescription)") 457 | // Here you might want to show an error alert to the user 458 | } 459 | } 460 | }) { 461 | HStack(spacing: 8) { 462 | Image(systemName: "square.and.arrow.down") 463 | Text("Download All") 464 | } 465 | .font(.system(size: 14, weight: .medium)) 466 | } 467 | .buttonStyle(.plain) 468 | .disabled(processor.files.allSatisfy { $0.result == nil }) 469 | } 470 | } 471 | .padding(20) 472 | } 473 | } 474 | 475 | struct FileItemView: View { 476 | let file: FileProcessingState 477 | let targetFormat: UTType 478 | let isHovered: Bool 479 | let onRemove: () -> Void 480 | @ObservedObject var processor: MultiFileProcessor 481 | @State private var showError = false 482 | @State private var errorMessage: String? 483 | 484 | var body: some View { 485 | HStack(spacing: 16) { 486 | // File icon 487 | Image(systemName: getFileIcon()) 488 | .font(.system(size: 24)) 489 | .foregroundColor(.accentColor) 490 | 491 | VStack(alignment: .leading, spacing: 4) { 492 | // Filename 493 | Text(file.displayFileName) 494 | .font(.system(size: 13)) 495 | .lineLimit(1) 496 | 497 | // Status 498 | if file.isProcessing { 499 | ProgressView() 500 | .scaleEffect(0.5) 501 | .frame(height: 2) 502 | } else if let error = file.error { 503 | Text(error.localizedDescription) 504 | .font(.system(size: 12)) 505 | .foregroundColor(.red) 506 | } else if file.result != nil { 507 | HStack(spacing: 4) { 508 | Image(systemName: "checkmark.circle.fill") 509 | .foregroundColor(.green) 510 | Text("Ready to Save") 511 | .foregroundColor(.secondary) 512 | } 513 | .font(.system(size: 12)) 514 | } 515 | } 516 | 517 | Spacer() 518 | 519 | // Actions 520 | HStack(spacing: 12) { 521 | if let result = file.result { 522 | Button(action: { 523 | Task { 524 | do { 525 | try await processor.saveConvertedFile(url: result.outputURL, originalName: file.originalFileName) 526 | } catch { 527 | errorMessage = error.localizedDescription 528 | showError = true 529 | } 530 | } 531 | }) { 532 | Image(systemName: "square.and.arrow.down") 533 | .font(.system(size: 14)) 534 | .foregroundColor(.accentColor) 535 | } 536 | .buttonStyle(.plain) 537 | .opacity(isHovered ? 1 : 0) 538 | } 539 | 540 | Button(action: onRemove) { 541 | Image(systemName: "xmark") 542 | .font(.system(size: 14)) 543 | .foregroundColor(.secondary) 544 | } 545 | .buttonStyle(.plain) 546 | .opacity(isHovered ? 1 : 0) 547 | } 548 | } 549 | .padding(.horizontal, 16) 550 | .padding(.vertical, 12) 551 | .background( 552 | RoundedRectangle(cornerRadius: 12) 553 | .fill(isHovered ? Color(NSColor.controlBackgroundColor) : Color.clear) 554 | ) 555 | .animation(.easeInOut(duration: 0.2), value: isHovered) 556 | .alert("Error", isPresented: $showError) { 557 | Button("OK", role: .cancel) {} 558 | } message: { 559 | Text(errorMessage ?? "An unknown error occurred") 560 | } 561 | } 562 | 563 | private func getFileIcon() -> String { 564 | if let preferredExtension = targetFormat.preferredFilenameExtension, 565 | file.url.pathExtension.lowercased() == preferredExtension.lowercased() { 566 | return "doc.circle" 567 | } 568 | return "arrow.triangle.2.circlepath" 569 | } 570 | } 571 | -------------------------------------------------------------------------------- /Convierto/Views/ProcessingView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | class ProcessingViewModel: ObservableObject { 4 | @Published var currentStage: ConversionStage = .idle 5 | @Published var progress: Double = 0 6 | @Published var isAnimating = false 7 | 8 | private var stageObserver: NSObjectProtocol? 9 | private var progressObserver: NSObjectProtocol? 10 | 11 | func setupNotificationObservers() { 12 | stageObserver = NotificationCenter.default.addObserver( 13 | forName: .processingStageChanged, 14 | object: nil, 15 | queue: .main 16 | ) { [weak self] notification in 17 | guard let stage = notification.userInfo?["stage"] as? ConversionStage else { return } 18 | 19 | withAnimation(.easeInOut(duration: 0.3)) { 20 | self?.currentStage = stage 21 | self?.updateAnimationState(for: stage) 22 | } 23 | } 24 | 25 | progressObserver = NotificationCenter.default.addObserver( 26 | forName: .processingProgressUpdated, 27 | object: nil, 28 | queue: .main 29 | ) { [weak self] notification in 30 | guard let newProgress = notification.userInfo?["progress"] as? Double else { return } 31 | 32 | withAnimation(.linear(duration: 0.2)) { 33 | self?.progress = max(0, min(1, newProgress)) 34 | } 35 | } 36 | } 37 | 38 | func removeNotificationObservers() { 39 | if let observer = stageObserver { 40 | NotificationCenter.default.removeObserver(observer) 41 | } 42 | if let observer = progressObserver { 43 | NotificationCenter.default.removeObserver(observer) 44 | } 45 | } 46 | 47 | private func updateAnimationState(for stage: ConversionStage) { 48 | isAnimating = stage.shouldAnimate 49 | } 50 | } 51 | 52 | struct ProcessingView: View { 53 | @StateObject private var viewModel = ProcessingViewModel() 54 | let onCancel: () -> Void 55 | 56 | var body: some View { 57 | VStack(spacing: 24) { 58 | ZStack { 59 | Circle() 60 | .stroke(Color.secondary.opacity(0.2), lineWidth: 4) 61 | .frame(width: 64, height: 64) 62 | 63 | Circle() 64 | .trim(from: 0, to: max(0.01, viewModel.progress)) // Ensure minimum visible progress 65 | .stroke( 66 | LinearGradient( 67 | colors: [.accentColor, .accentColor.opacity(0.8)], 68 | startPoint: .top, 69 | endPoint: .bottom 70 | ), 71 | style: StrokeStyle(lineWidth: 4, lineCap: .round) 72 | ) 73 | .frame(width: 64, height: 64) 74 | .rotationEffect(.degrees(-90)) 75 | .animation(.linear(duration: 0.2), value: viewModel.progress) 76 | 77 | Image(systemName: getStageIcon()) 78 | .font(.system(size: 24, weight: .medium)) 79 | .foregroundStyle( 80 | LinearGradient( 81 | colors: [.accentColor, .accentColor.opacity(0.8)], 82 | startPoint: .top, 83 | endPoint: .bottom 84 | ) 85 | ) 86 | .opacity(viewModel.isAnimating ? 0.5 : 1.0) 87 | .animation( 88 | viewModel.isAnimating ? 89 | .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : 90 | .default, 91 | value: viewModel.isAnimating 92 | ) 93 | } 94 | 95 | Text(getStageText()) 96 | .font(.system(size: 16, weight: .medium)) 97 | 98 | Text("\(Int(viewModel.progress * 100))%") 99 | .font(.system(size: 14)) 100 | .foregroundColor(.secondary) 101 | .contentTransition(.numericText()) 102 | 103 | Button(action: { 104 | withAnimation(.spring(response: 0.3)) { 105 | onCancel() 106 | } 107 | }) { 108 | Text("Cancel") 109 | .font(.system(size: 14, weight: .medium)) 110 | .foregroundColor(.secondary) 111 | } 112 | .buttonStyle(.plain) 113 | .padding(.horizontal, 16) 114 | .padding(.vertical, 8) 115 | .background( 116 | RoundedRectangle(cornerRadius: 8) 117 | .stroke(Color.secondary.opacity(0.2), lineWidth: 1) 118 | ) 119 | } 120 | .onAppear { 121 | viewModel.setupNotificationObservers() 122 | startAnimating() 123 | } 124 | .onDisappear { 125 | viewModel.removeNotificationObservers() 126 | stopAnimating() 127 | } 128 | } 129 | 130 | private func startAnimating() { 131 | viewModel.isAnimating = viewModel.currentStage.shouldAnimate 132 | } 133 | 134 | private func stopAnimating() { 135 | viewModel.isAnimating = false 136 | } 137 | 138 | private func getStageIcon() -> String { 139 | switch viewModel.currentStage { 140 | case .idle: return "gear" 141 | case .analyzing: return "magnifyingglass" 142 | case .converting: return "arrow.triangle.2.circlepath" 143 | case .optimizing: return "slider.horizontal.3" 144 | case .finalizing: return "checkmark.circle" 145 | case .completed: return "checkmark.circle.fill" 146 | case .failed: return "xmark.circle" 147 | case .preparing: return "gear" 148 | } 149 | } 150 | 151 | private func getStageText() -> String { 152 | switch viewModel.currentStage { 153 | case .idle: return "Preparing..." 154 | case .analyzing: return "Analyzing..." 155 | case .converting: return "Converting..." 156 | case .optimizing: return "Optimizing..." 157 | case .finalizing: return "Finalizing..." 158 | case .completed: return "Completed" 159 | case .failed: return "Failed" 160 | case .preparing: return "Preparing..." 161 | } 162 | } 163 | } 164 | 165 | // Add extension to ConversionStage 166 | extension ConversionStage { 167 | var shouldAnimate: Bool { 168 | switch self { 169 | case .idle, .analyzing, .converting, .optimizing, .finalizing, .preparing: 170 | return true 171 | case .completed, .failed: 172 | return false 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Convierto/Views/ResultView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ResultView: View { 4 | let result: ProcessingResult 5 | let onDownload: () -> Void 6 | let onReset: () -> Void 7 | @State private var isHovering = false 8 | @State private var showCopied = false 9 | @State private var showError = false 10 | @State private var errorMessage: String? 11 | 12 | var body: some View { 13 | VStack(spacing: 32) { 14 | // Success icon 15 | ZStack { 16 | Circle() 17 | .fill(Color.accentColor.opacity(0.1)) 18 | .frame(width: 80, height: 80) 19 | 20 | Image(systemName: "checkmark.circle.fill") 21 | .font(.system(size: 40, weight: .medium)) 22 | .foregroundStyle( 23 | LinearGradient( 24 | colors: [.accentColor, .accentColor.opacity(0.8)], 25 | startPoint: .top, 26 | endPoint: .bottom 27 | ) 28 | ) 29 | } 30 | 31 | // Status text 32 | VStack(spacing: 8) { 33 | Text("Conversion Complete") 34 | .font(.system(size: 16, weight: .medium)) 35 | 36 | Text("Ready to save") 37 | .font(.system(size: 14)) 38 | .foregroundColor(.secondary) 39 | } 40 | 41 | // File info card 42 | VStack(spacing: 16) { 43 | HStack { 44 | Text("File Details") 45 | .font(.system(size: 14, weight: .medium)) 46 | Spacer() 47 | } 48 | 49 | VStack(spacing: 12) { 50 | InfoRow( 51 | title: "Original", 52 | value: result.originalFileName, 53 | icon: "doc" 54 | ) 55 | 56 | Divider() 57 | .opacity(0.5) 58 | 59 | InfoRow( 60 | title: "Converted", 61 | value: result.suggestedFileName, 62 | icon: "doc.fill" 63 | ) 64 | } 65 | .padding(16) 66 | .background( 67 | RoundedRectangle(cornerRadius: 12) 68 | .fill(Color(NSColor.controlBackgroundColor)) 69 | .opacity(0.5) 70 | ) 71 | } 72 | .padding(.horizontal) 73 | 74 | // Action buttons 75 | HStack(spacing: 12) { 76 | Button(action: { 77 | withAnimation(.spring(response: 0.3)) { 78 | onReset() 79 | } 80 | }) { 81 | HStack(spacing: 8) { 82 | Image(systemName: "arrow.counterclockwise") 83 | Text("Convert Another") 84 | } 85 | .font(.system(size: 14, weight: .medium)) 86 | } 87 | .buttonStyle(.plain) 88 | .padding(.horizontal, 16) 89 | .padding(.vertical, 8) 90 | .background( 91 | RoundedRectangle(cornerRadius: 8) 92 | .stroke(Color.secondary.opacity(0.2), lineWidth: 1) 93 | ) 94 | 95 | Button(action: { 96 | Task { 97 | do { 98 | // Verify file exists and is accessible 99 | guard FileManager.default.fileExists(atPath: result.outputURL.path), 100 | FileManager.default.isReadableFile(atPath: result.outputURL.path) else { 101 | throw ConversionError.exportFailed(reason: "The converted file is no longer accessible") 102 | } 103 | 104 | // Create a temporary copy before saving 105 | let tempURL = try FileManager.default.url( 106 | for: .itemReplacementDirectory, 107 | in: .userDomainMask, 108 | appropriateFor: result.outputURL, 109 | create: true 110 | ).appendingPathComponent(result.suggestedFileName) 111 | 112 | try FileManager.default.copyItem(at: result.outputURL, to: tempURL) 113 | 114 | // Update the result with the new temporary URL 115 | _ = ProcessingResult( 116 | outputURL: tempURL, 117 | originalFileName: result.originalFileName, 118 | suggestedFileName: result.suggestedFileName, 119 | fileType: result.fileType, 120 | metadata: result.metadata 121 | ) 122 | 123 | // Perform the save operation 124 | withAnimation(.spring(response: 0.3)) { 125 | onDownload() 126 | } 127 | } catch { 128 | errorMessage = error.localizedDescription 129 | showError = true 130 | } 131 | } 132 | }) { 133 | HStack(spacing: 8) { 134 | Image(systemName: "square.and.arrow.down") 135 | Text("Save File") 136 | } 137 | .font(.system(size: 14, weight: .medium)) 138 | .foregroundColor(.white) 139 | } 140 | .buttonStyle(.plain) 141 | .padding(.horizontal, 16) 142 | .padding(.vertical, 8) 143 | .background( 144 | RoundedRectangle(cornerRadius: 8) 145 | .fill( 146 | LinearGradient( 147 | colors: [.accentColor, .accentColor.opacity(0.8)], 148 | startPoint: .top, 149 | endPoint: .bottom 150 | ) 151 | ) 152 | ) 153 | .shadow(color: .accentColor.opacity(0.2), radius: 8, y: 4) 154 | } 155 | } 156 | .padding(24) 157 | .frame(maxWidth: .infinity, maxHeight: .infinity) 158 | .alert("Error", isPresented: $showError) { 159 | Button("OK", role: .cancel) {} 160 | } message: { 161 | Text(errorMessage ?? "An unknown error occurred") 162 | } 163 | } 164 | } 165 | 166 | struct InfoRow: View { 167 | let title: String 168 | let value: String 169 | let icon: String 170 | @State private var isHovering = false 171 | @State private var showCopied = false 172 | 173 | var body: some View { 174 | HStack(spacing: 12) { 175 | Image(systemName: icon) 176 | .font(.system(size: 14)) 177 | .foregroundColor(.secondary) 178 | 179 | VStack(alignment: .leading, spacing: 2) { 180 | Text(title) 181 | .font(.system(size: 12)) 182 | .foregroundColor(.secondary) 183 | 184 | Text(value) 185 | .font(.system(size: 13, weight: .medium)) 186 | .lineLimit(1) 187 | } 188 | 189 | Spacer() 190 | 191 | if isHovering { 192 | Button { 193 | NSPasteboard.general.clearContents() 194 | NSPasteboard.general.setString(value, forType: .string) 195 | 196 | withAnimation { 197 | showCopied = true 198 | } 199 | 200 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { 201 | withAnimation { 202 | showCopied = false 203 | } 204 | } 205 | } label: { 206 | Image(systemName: showCopied ? "checkmark" : "doc.on.doc") 207 | .font(.system(size: 12)) 208 | .foregroundColor(.secondary) 209 | } 210 | .buttonStyle(.plain) 211 | .transition(.opacity) 212 | } 213 | } 214 | .onHover { hovering in 215 | withAnimation(.easeInOut(duration: 0.2)) { 216 | isHovering = hovering 217 | } 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 nuance-dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Convierto - A Free macOS Native File Converter 2 | 3 | A powerful, native macOS app that intelligently converts files while maintaining quality. Built with SwiftUI for optimal performance and a seamless user experience. 4 | 5 | ![convierto-banner](https://github.com/user-attachments/assets/df5a1c1f-bc31-4a70-bacb-f9460d0b7fb1) 6 | 7 | ## ✨ Features 8 | 9 | ### File Support 10 | 11 | - **PDFs**: Smart conversion while preserving readability 12 | - **Images**: JPEG, PNG, HEIC, TIFF, GIF (including animated), BMP, WebP, SVG, RAW (CR2, NEF, ARW), ICO 13 | - **Videos**: MP4, MOV, AVI, MPEG/MPG 14 | - **Audio**: MP3, WAV, AIFF, M4A, AAC 15 | - **Documents**: PDF 16 | 17 | 18 | https://github.com/user-attachments/assets/c2dea2d9-8a25-4b5f-902a-5aeca3aafce2 19 | 20 | 21 | 22 | ### Core Features 23 | 24 | - **Smart Conversion**: Intelligent format detection and optimal conversion settings 25 | - **Batch Processing**: Convert multiple files simultaneously 26 | - **Real-time Progress**: Detailed conversion progress with stage indicators 27 | - **Format Preview**: Visual preview of input and output formats 28 | - **Drag & Drop**: Intuitive file handling with drag and drop support 29 | - **Dark Mode**: Seamless integration with macOS appearance settings 30 | - **Native Performance**: Built with SwiftUI for optimal macOS integration 31 | - **Secure**: Sandboxed for enhanced security 32 | 33 | ## 💻 Requirements 34 | 35 | - macOS 14.0 or later 36 | - Apple Silicon or Intel processor 37 | 38 | ## 🚀 Getting Started 39 | 40 | 1. Download the latest version from the [releases page](https://github.com/nuance-dev/Convierto/releases) 41 | 2. Open the app 42 | 3. Drop your files or click to browse 43 | 4. Select your desired output format 44 | 5. Let Convierto handle the rest! 45 | 46 | ## 🛠 Technical Details 47 | 48 | - Built with SwiftUI and modern Apple frameworks 49 | - Sandboxed for security 50 | - Optimized for Apple Silicon 51 | - Uses native macOS media processing capabilities 52 | 53 | ## 🔮 Coming Soon 54 | 55 | - Additional format support 56 | - Advanced conversion settings 57 | - Preset management 58 | - Keyboard shortcuts 59 | - Workflow automation 60 | 61 | ## 🤝 Contributing 62 | 63 | We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. 64 | 65 | ## 📝 License 66 | 67 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 68 | 69 | ## 🔗 Links 70 | 71 | - Website: [Nuance](https://nuanc.me) 72 | - Issues: [GitHub Issues](https://github.com/nuance-dev/Convierto/issues) 73 | - Updates: [@NuanceDev](https://twitter.com/Nuancedev) 74 | --------------------------------------------------------------------------------