Implement core project structure and foundation components
Project Setup (Spec 01): - Configure iOS 17.0 deployment target - Add photo library usage descriptions - Create proper folder structure (App, Features, Services, Models, Utilities) Data Model (Spec 02): - Add EditOperation enum with mask, inpaint, adjustment cases - Add MaskOperation, InpaintOperation structs (Codable) - Add Project model with operation stack and undo/redo support - Add MaskData with dilation support via vImage Services (Specs 03-05): - Add InpaintEngine with Metal + Accelerate fallback - Add MaskingService wrapping Vision framework - Add ContourService for wire/line detection and scoring UI Components (Specs 06-08): - Add PhotoEditorView with photo picker integration - Add CanvasView with pinch-to-zoom and pan gestures - Add ToolbarView with tool selection and inspector panel Utilities: - Add ImagePipeline for preview/export rendering - Add EdgeRefinement for smart brush edge detection - Add check_build.sh for CI verification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
353
CheapRetouch.xcodeproj/project.pbxproj
Normal file
353
CheapRetouch.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
7F13382D2F247C1A007FA81B /* CheapRetouch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CheapRetouch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
7F13382F2F247C1A007FA81B /* CheapRetouch */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = CheapRetouch;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
7F13382A2F247C1A007FA81B /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
7F1338242F247C1A007FA81B = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F13382F2F247C1A007FA81B /* CheapRetouch */,
|
||||||
|
7F13382E2F247C1A007FA81B /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7F13382E2F247C1A007FA81B /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7F13382D2F247C1A007FA81B /* CheapRetouch.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
7F13382C2F247C1A007FA81B /* CheapRetouch */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 7F1338382F247C1A007FA81B /* Build configuration list for PBXNativeTarget "CheapRetouch" */;
|
||||||
|
buildPhases = (
|
||||||
|
7F1338292F247C1A007FA81B /* Sources */,
|
||||||
|
7F13382A2F247C1A007FA81B /* Frameworks */,
|
||||||
|
7F13382B2F247C1A007FA81B /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
7F13382F2F247C1A007FA81B /* CheapRetouch */,
|
||||||
|
);
|
||||||
|
name = CheapRetouch;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = CheapRetouch;
|
||||||
|
productReference = 7F13382D2F247C1A007FA81B /* CheapRetouch.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
7F1338252F247C1A007FA81B /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2620;
|
||||||
|
LastUpgradeCheck = 2620;
|
||||||
|
TargetAttributes = {
|
||||||
|
7F13382C2F247C1A007FA81B = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 7F1338282F247C1A007FA81B /* Build configuration list for PBXProject "CheapRetouch" */;
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = 7F1338242F247C1A007FA81B;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
productRefGroup = 7F13382E2F247C1A007FA81B /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
7F13382C2F247C1A007FA81B /* CheapRetouch */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
7F13382B2F247C1A007FA81B /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
7F1338292F247C1A007FA81B /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
7F1338362F247C1A007FA81B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
7F1338372F247C1A007FA81B /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
7F1338392F247C1A007FA81B /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = CheapRetouch;
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "CheapRetouch needs permission to save edited photos to your library.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "CheapRetouch needs access to your photos to edit and remove unwanted elements from them.";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.CheapRetouch;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
7F13383A2F247C1A007FA81B /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 7X85543FQQ;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = CheapRetouch;
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "CheapRetouch needs permission to save edited photos to your library.";
|
||||||
|
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "CheapRetouch needs access to your photos to edit and remove unwanted elements from them.";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.jaredlog.CheapRetouch;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
7F1338282F247C1A007FA81B /* Build configuration list for PBXProject "CheapRetouch" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7F1338362F247C1A007FA81B /* Debug */,
|
||||||
|
7F1338372F247C1A007FA81B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
7F1338382F247C1A007FA81B /* Build configuration list for PBXNativeTarget "CheapRetouch" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7F1338392F247C1A007FA81B /* Debug */,
|
||||||
|
7F13383A2F247C1A007FA81B /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 7F1338252F247C1A007FA81B /* Project object */;
|
||||||
|
}
|
||||||
17
CheapRetouch/App/CheapRetouchApp.swift
Normal file
17
CheapRetouch/App/CheapRetouchApp.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// CheapRetouchApp.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Created by Jared Evans on 1/23/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CheapRetouchApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
PhotoEditorView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
CheapRetouch/Assets.xcassets/Contents.json
Normal file
6
CheapRetouch/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
129
CheapRetouch/Features/Editor/CanvasView.swift
Normal file
129
CheapRetouch/Features/Editor/CanvasView.swift
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//
|
||||||
|
// CanvasView.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Main editing canvas with pinch-to-zoom, pan, mask overlay, and before/after comparison.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct CanvasView: View {
|
||||||
|
let image: UIImage
|
||||||
|
|
||||||
|
@State private var scale: CGFloat = 1.0
|
||||||
|
@State private var lastScale: CGFloat = 1.0
|
||||||
|
@State private var offset: CGSize = .zero
|
||||||
|
@State private var lastOffset: CGSize = .zero
|
||||||
|
@State private var isShowingOriginal = false
|
||||||
|
|
||||||
|
private let minScale: CGFloat = 1.0
|
||||||
|
private let maxScale: CGFloat = 10.0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.scaleEffect(scale)
|
||||||
|
.offset(offset)
|
||||||
|
.gesture(
|
||||||
|
MagnificationGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
let newScale = lastScale * value
|
||||||
|
scale = min(max(newScale, minScale), maxScale)
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastScale = scale
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
clampOffset(in: geometry.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.simultaneousGesture(
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
if scale > 1.0 {
|
||||||
|
offset = CGSize(
|
||||||
|
width: lastOffset.width + value.translation.width,
|
||||||
|
height: lastOffset.height + value.translation.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
lastOffset = offset
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
clampOffset(in: geometry.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.simultaneousGesture(
|
||||||
|
LongPressGesture(minimumDuration: 0.2)
|
||||||
|
.onChanged { _ in
|
||||||
|
isShowingOriginal = true
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
isShowingOriginal = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
withAnimation(.spring(duration: 0.3)) {
|
||||||
|
if scale > 1.0 {
|
||||||
|
scale = 1.0
|
||||||
|
offset = .zero
|
||||||
|
lastScale = 1.0
|
||||||
|
lastOffset = .zero
|
||||||
|
} else {
|
||||||
|
scale = 2.5
|
||||||
|
lastScale = 2.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clampOffset(in size: CGSize) {
|
||||||
|
guard scale > 1.0 else {
|
||||||
|
offset = .zero
|
||||||
|
lastOffset = .zero
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageAspect = image.size.width / image.size.height
|
||||||
|
let viewAspect = size.width / size.height
|
||||||
|
|
||||||
|
let imageDisplaySize: CGSize
|
||||||
|
if imageAspect > viewAspect {
|
||||||
|
imageDisplaySize = CGSize(
|
||||||
|
width: size.width,
|
||||||
|
height: size.width / imageAspect
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
imageDisplaySize = CGSize(
|
||||||
|
width: size.height * imageAspect,
|
||||||
|
height: size.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let scaledSize = CGSize(
|
||||||
|
width: imageDisplaySize.width * scale,
|
||||||
|
height: imageDisplaySize.height * scale
|
||||||
|
)
|
||||||
|
|
||||||
|
let maxOffsetX = max(0, (scaledSize.width - size.width) / 2)
|
||||||
|
let maxOffsetY = max(0, (scaledSize.height - size.height) / 2)
|
||||||
|
|
||||||
|
offset.width = min(max(offset.width, -maxOffsetX), maxOffsetX)
|
||||||
|
offset.height = min(max(offset.height, -maxOffsetY), maxOffsetY)
|
||||||
|
lastOffset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
CanvasView(image: UIImage(systemName: "photo")!)
|
||||||
|
}
|
||||||
88
CheapRetouch/Features/Editor/PhotoEditorView.swift
Normal file
88
CheapRetouch/Features/Editor/PhotoEditorView.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// PhotoEditorView.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Main editor view composing canvas, toolbar, and coordinating edit operations.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct PhotoEditorView: View {
|
||||||
|
@State private var selectedImage: UIImage?
|
||||||
|
@State private var selectedItem: PhotosPickerItem?
|
||||||
|
@State private var isShowingPicker = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
Color(.systemBackground)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
if let image = selectedImage {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
CanvasView(image: image)
|
||||||
|
ToolbarView()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
EmptyStateView(isShowingPicker: $isShowingPicker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("CheapRetouch")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
PhotosPicker(selection: $selectedItem, matching: .images) {
|
||||||
|
Image(systemName: "photo.on.rectangle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: selectedItem) { oldValue, newValue in
|
||||||
|
Task {
|
||||||
|
if let data = try? await newValue?.loadTransferable(type: Data.self),
|
||||||
|
let uiImage = UIImage(data: data) {
|
||||||
|
selectedImage = uiImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.photosPicker(isPresented: $isShowingPicker, selection: $selectedItem, matching: .images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EmptyStateView: View {
|
||||||
|
@Binding var isShowingPicker: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "photo.badge.plus")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("No Photo Selected")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
|
||||||
|
Text("Import a photo to start removing\nunwanted elements")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
isShowingPicker = true
|
||||||
|
} label: {
|
||||||
|
Label("Select Photo", systemImage: "photo.on.rectangle")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
PhotoEditorView()
|
||||||
|
}
|
||||||
176
CheapRetouch/Features/Editor/ToolbarView.swift
Normal file
176
CheapRetouch/Features/Editor/ToolbarView.swift
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
//
|
||||||
|
// ToolbarView.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Tool selection toolbar with contextual inspector panel.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum EditTool: String, CaseIterable, Identifiable {
|
||||||
|
case person = "Person"
|
||||||
|
case object = "Object"
|
||||||
|
case wire = "Wire"
|
||||||
|
case brush = "Brush"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .person: return "person.fill"
|
||||||
|
case .object: return "circle.dashed"
|
||||||
|
case .wire: return "line.diagonal"
|
||||||
|
case .brush: return "paintbrush.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .person: return "Tap to remove people"
|
||||||
|
case .object: return "Tap to remove objects"
|
||||||
|
case .wire: return "Tap to remove wires"
|
||||||
|
case .brush: return "Paint to select areas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolbarView: View {
|
||||||
|
@State private var selectedTool: EditTool = .person
|
||||||
|
@State private var brushSize: Double = 20
|
||||||
|
@State private var featherAmount: Double = 4
|
||||||
|
@State private var wireWidth: Double = 6
|
||||||
|
@State private var canUndo = false
|
||||||
|
@State private var canRedo = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Inspector panel (contextual)
|
||||||
|
if selectedTool == .brush || selectedTool == .wire {
|
||||||
|
inspectorPanel
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main toolbar
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Tools
|
||||||
|
ForEach(EditTool.allCases) { tool in
|
||||||
|
toolButton(for: tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Undo/Redo
|
||||||
|
Button {
|
||||||
|
// Undo action
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.uturn.backward")
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.disabled(!canUndo)
|
||||||
|
.accessibilityLabel("Undo")
|
||||||
|
|
||||||
|
Button {
|
||||||
|
// Redo action
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.uturn.forward")
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
.disabled(!canRedo)
|
||||||
|
.accessibilityLabel("Redo")
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: selectedTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toolButton(for tool: EditTool) -> some View {
|
||||||
|
Button {
|
||||||
|
selectedTool = tool
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: tool.icon)
|
||||||
|
.font(.title2)
|
||||||
|
.frame(width: 44, height: 32)
|
||||||
|
|
||||||
|
Text(tool.rawValue)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(selectedTool == tool ? Color.accentColor : Color.secondary)
|
||||||
|
.frame(width: 60)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("\(tool.rawValue) tool")
|
||||||
|
.accessibilityHint(tool.description)
|
||||||
|
.accessibilityAddTraits(selectedTool == tool ? .isSelected : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inspectorPanel: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
if selectedTool == .brush {
|
||||||
|
HStack {
|
||||||
|
Text("Brush Size")
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(brushSize))px")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Slider(value: $brushSize, in: 1...100, step: 1)
|
||||||
|
.accessibilityLabel("Brush size slider")
|
||||||
|
|
||||||
|
Stepper("", value: $brushSize, in: 1...100, step: 1)
|
||||||
|
.labelsHidden()
|
||||||
|
.accessibilityLabel("Brush size stepper")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedTool == .wire {
|
||||||
|
HStack {
|
||||||
|
Text("Line Width")
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(wireWidth))px")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(value: $wireWidth, in: 2...20, step: 1)
|
||||||
|
.accessibilityLabel("Wire width slider")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Feather")
|
||||||
|
.font(.subheadline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(featherAmount))px")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(value: $featherAmount, in: 0...20, step: 1)
|
||||||
|
.accessibilityLabel("Feather amount slider")
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
ToolbarView()
|
||||||
|
}
|
||||||
|
}
|
||||||
71
CheapRetouch/Models/EditOperation.swift
Normal file
71
CheapRetouch/Models/EditOperation.swift
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//
|
||||||
|
// EditOperation.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Non-destructive editing data model with operation stack.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ToolType: String, Codable {
|
||||||
|
case person
|
||||||
|
case object
|
||||||
|
case wire
|
||||||
|
case brush
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EditOperation: Codable {
|
||||||
|
case mask(MaskOperation)
|
||||||
|
case inpaint(InpaintOperation)
|
||||||
|
case adjustment(AdjustmentOperation)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MaskOperation: Codable, Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let toolType: ToolType
|
||||||
|
let maskData: Data
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), toolType: ToolType, maskData: Data, timestamp: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.toolType = toolType
|
||||||
|
self.maskData = maskData
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InpaintOperation: Codable, Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let maskOperationId: UUID
|
||||||
|
let patchRadius: Int
|
||||||
|
let featherAmount: Float
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), maskOperationId: UUID, patchRadius: Int = 9, featherAmount: Float = 4.0, timestamp: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.maskOperationId = maskOperationId
|
||||||
|
self.patchRadius = patchRadius
|
||||||
|
self.featherAmount = featherAmount
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdjustmentOperation: Codable, Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let type: AdjustmentType
|
||||||
|
let value: Float
|
||||||
|
let timestamp: Date
|
||||||
|
|
||||||
|
enum AdjustmentType: String, Codable {
|
||||||
|
case brightness
|
||||||
|
case contrast
|
||||||
|
case saturation
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), type: AdjustmentType, value: Float, timestamp: Date = Date()) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.value = value
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
112
CheapRetouch/Models/MaskData.swift
Normal file
112
CheapRetouch/Models/MaskData.swift
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//
|
||||||
|
// MaskData.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Mask data utilities for compression and decompression.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
import UIKit
|
||||||
|
import Accelerate
|
||||||
|
|
||||||
|
struct MaskData {
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
let data: Data
|
||||||
|
|
||||||
|
init(width: Int, height: Int, data: Data) {
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(from cgImage: CGImage) {
|
||||||
|
let width = cgImage.width
|
||||||
|
let height = cgImage.height
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: width,
|
||||||
|
space: CGColorSpaceCreateDeviceGray(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
|
||||||
|
guard let data = context.data else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = Data(bytes: data, count: width * height)
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.data = buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func toCGImage() -> CGImage? {
|
||||||
|
var mutableData = data
|
||||||
|
return mutableData.withUnsafeMutableBytes { rawBuffer -> CGImage? in
|
||||||
|
guard let baseAddress = rawBuffer.baseAddress else { return nil }
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: baseAddress,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: width,
|
||||||
|
space: CGColorSpaceCreateDeviceGray(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.makeImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dilated(by pixels: Int) -> MaskData? {
|
||||||
|
guard pixels > 0 else { return self }
|
||||||
|
|
||||||
|
let count = width * height
|
||||||
|
var sourceArray = [UInt8](data)
|
||||||
|
var destinationArray = [UInt8](repeating: 0, count: count)
|
||||||
|
|
||||||
|
var sourceBuffer = vImage_Buffer(
|
||||||
|
data: &sourceArray,
|
||||||
|
height: vImagePixelCount(height),
|
||||||
|
width: vImagePixelCount(width),
|
||||||
|
rowBytes: width
|
||||||
|
)
|
||||||
|
|
||||||
|
var destinationBuffer = vImage_Buffer(
|
||||||
|
data: &destinationArray,
|
||||||
|
height: vImagePixelCount(height),
|
||||||
|
width: vImagePixelCount(width),
|
||||||
|
rowBytes: width
|
||||||
|
)
|
||||||
|
|
||||||
|
let kernelSize = pixels * 2 + 1
|
||||||
|
let kernel = [UInt8](repeating: 255, count: kernelSize * kernelSize)
|
||||||
|
|
||||||
|
let error = vImageDilate_Planar8(
|
||||||
|
&sourceBuffer,
|
||||||
|
&destinationBuffer,
|
||||||
|
0, 0,
|
||||||
|
kernel,
|
||||||
|
vImagePixelCount(kernelSize),
|
||||||
|
vImagePixelCount(kernelSize),
|
||||||
|
vImage_Flags(kvImageNoFlags)
|
||||||
|
)
|
||||||
|
|
||||||
|
guard error == kvImageNoError else { return nil }
|
||||||
|
|
||||||
|
return MaskData(width: width, height: height, data: Data(destinationArray))
|
||||||
|
}
|
||||||
|
}
|
||||||
77
CheapRetouch/Models/Project.swift
Normal file
77
CheapRetouch/Models/Project.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// Project.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Project model holding original image reference and operation stack.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Project: Codable, Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
var name: String
|
||||||
|
var imageSource: ImageSource
|
||||||
|
var operations: [EditOperation]
|
||||||
|
var currentIndex: Int
|
||||||
|
let createdAt: Date
|
||||||
|
var modifiedAt: Date
|
||||||
|
|
||||||
|
enum ImageSource: Codable {
|
||||||
|
case photoLibrary(localIdentifier: String)
|
||||||
|
case embedded(data: Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String = "Untitled",
|
||||||
|
imageSource: ImageSource,
|
||||||
|
operations: [EditOperation] = [],
|
||||||
|
currentIndex: Int = 0,
|
||||||
|
createdAt: Date = Date(),
|
||||||
|
modifiedAt: Date = Date()
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.imageSource = imageSource
|
||||||
|
self.operations = operations
|
||||||
|
self.currentIndex = currentIndex
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.modifiedAt = modifiedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var canUndo: Bool {
|
||||||
|
currentIndex > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var canRedo: Bool {
|
||||||
|
currentIndex < operations.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func addOperation(_ operation: EditOperation) {
|
||||||
|
// Remove any operations after current index (discard redo stack)
|
||||||
|
if currentIndex < operations.count {
|
||||||
|
operations.removeSubrange(currentIndex..<operations.count)
|
||||||
|
}
|
||||||
|
operations.append(operation)
|
||||||
|
currentIndex = operations.count
|
||||||
|
modifiedAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func undo() -> Bool {
|
||||||
|
guard canUndo else { return false }
|
||||||
|
currentIndex -= 1
|
||||||
|
modifiedAt = Date()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func redo() -> Bool {
|
||||||
|
guard canRedo else { return false }
|
||||||
|
currentIndex += 1
|
||||||
|
modifiedAt = Date()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeOperations: [EditOperation] {
|
||||||
|
Array(operations.prefix(currentIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
235
CheapRetouch/Services/ContourService.swift
Normal file
235
CheapRetouch/Services/ContourService.swift
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
//
|
||||||
|
// ContourService.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Service for detecting and scoring wire/line contours.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
import CoreGraphics
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
actor ContourService {
|
||||||
|
|
||||||
|
struct ScoredContour {
|
||||||
|
let contour: VNContour
|
||||||
|
let score: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
private let proximityWeight: Float = 0.3
|
||||||
|
private let aspectWeight: Float = 0.3
|
||||||
|
private let straightnessWeight: Float = 0.2
|
||||||
|
private let lengthWeight: Float = 0.2
|
||||||
|
|
||||||
|
private let minimumScore: Float = 0.3
|
||||||
|
|
||||||
|
func detectContours(in image: CGImage) async throws -> [VNContour] {
|
||||||
|
let request = VNDetectContoursRequest()
|
||||||
|
request.contrastAdjustment = 1.0
|
||||||
|
request.detectsDarkOnLight = true
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, options: [:])
|
||||||
|
try handler.perform([request])
|
||||||
|
|
||||||
|
guard let results = request.results?.first else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectAllContours(from: results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBestWireContour(at point: CGPoint, from contours: [VNContour], imageSize: CGSize) -> VNContour? {
|
||||||
|
let normalizedPoint = CGPoint(
|
||||||
|
x: point.x / imageSize.width,
|
||||||
|
y: 1.0 - point.y / imageSize.height
|
||||||
|
)
|
||||||
|
|
||||||
|
let scoredContours = contours.compactMap { contour -> ScoredContour? in
|
||||||
|
let score = scoreContour(contour, relativeTo: normalizedPoint)
|
||||||
|
guard score >= minimumScore else { return nil }
|
||||||
|
return ScoredContour(contour: contour, score: score)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scoredContours.max(by: { $0.score < $1.score })?.contour
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreContour(_ contour: VNContour, relativeTo point: CGPoint) -> Float {
|
||||||
|
let points = contour.normalizedPoints
|
||||||
|
|
||||||
|
guard points.count >= 2 else { return 0 }
|
||||||
|
|
||||||
|
let proximityScore = calculateProximityScore(points: points, to: point)
|
||||||
|
let aspectScore = calculateAspectScore(points: points)
|
||||||
|
let straightnessScore = calculateStraightnessScore(points: points)
|
||||||
|
let lengthScore = calculateLengthScore(points: points)
|
||||||
|
|
||||||
|
return proximityScore * proximityWeight +
|
||||||
|
aspectScore * aspectWeight +
|
||||||
|
straightnessScore * straightnessWeight +
|
||||||
|
lengthScore * lengthWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func contourToMask(_ contour: VNContour, width: Int, imageSize: CGSize) -> CGImage? {
|
||||||
|
let maskWidth = Int(imageSize.width)
|
||||||
|
let maskHeight = Int(imageSize.height)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: maskWidth,
|
||||||
|
height: maskHeight,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: maskWidth,
|
||||||
|
space: CGColorSpaceCreateDeviceGray(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setFillColor(gray: 0, alpha: 1)
|
||||||
|
context.fill(CGRect(x: 0, y: 0, width: maskWidth, height: maskHeight))
|
||||||
|
|
||||||
|
context.setStrokeColor(gray: 1, alpha: 1)
|
||||||
|
context.setLineWidth(CGFloat(width))
|
||||||
|
context.setLineCap(.round)
|
||||||
|
context.setLineJoin(.round)
|
||||||
|
|
||||||
|
let points = contour.normalizedPoints
|
||||||
|
guard let firstPoint = points.first else { return nil }
|
||||||
|
|
||||||
|
context.beginPath()
|
||||||
|
context.move(to: CGPoint(
|
||||||
|
x: CGFloat(firstPoint.x) * imageSize.width,
|
||||||
|
y: CGFloat(firstPoint.y) * imageSize.height
|
||||||
|
))
|
||||||
|
|
||||||
|
for point in points.dropFirst() {
|
||||||
|
context.addLine(to: CGPoint(
|
||||||
|
x: CGFloat(point.x) * imageSize.width,
|
||||||
|
y: CGFloat(point.y) * imageSize.height
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
context.strokePath()
|
||||||
|
|
||||||
|
return context.makeImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Scoring Methods
|
||||||
|
|
||||||
|
private func calculateProximityScore(points: [SIMD2<Float>], to target: CGPoint) -> Float {
|
||||||
|
var minDistance: Float = .greatestFiniteMagnitude
|
||||||
|
|
||||||
|
for point in points {
|
||||||
|
let dx = Float(target.x) - point.x
|
||||||
|
let dy = Float(target.y) - point.y
|
||||||
|
let distance = sqrt(dx * dx + dy * dy)
|
||||||
|
minDistance = min(minDistance, distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score decreases with distance, max at 0, 0 at distance > 0.2
|
||||||
|
return max(0, 1.0 - minDistance * 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateAspectScore(points: [SIMD2<Float>]) -> Float {
|
||||||
|
guard points.count >= 2 else { return 0 }
|
||||||
|
|
||||||
|
var minX: Float = .greatestFiniteMagnitude
|
||||||
|
var maxX: Float = -.greatestFiniteMagnitude
|
||||||
|
var minY: Float = .greatestFiniteMagnitude
|
||||||
|
var maxY: Float = -.greatestFiniteMagnitude
|
||||||
|
|
||||||
|
for point in points {
|
||||||
|
minX = min(minX, point.x)
|
||||||
|
maxX = max(maxX, point.x)
|
||||||
|
minY = min(minY, point.y)
|
||||||
|
maxY = max(maxY, point.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = maxX - minX
|
||||||
|
let height = maxY - minY
|
||||||
|
|
||||||
|
let length = calculatePathLength(points: points)
|
||||||
|
let boundingDiagonal = sqrt(width * width + height * height)
|
||||||
|
|
||||||
|
guard boundingDiagonal > 0 else { return 0 }
|
||||||
|
|
||||||
|
// Wire-like shapes have length close to their bounding diagonal
|
||||||
|
// and small perpendicular extent
|
||||||
|
let minDimension = min(width, height)
|
||||||
|
let maxDimension = max(width, height)
|
||||||
|
|
||||||
|
guard maxDimension > 0 else { return 0 }
|
||||||
|
|
||||||
|
let aspectRatio = minDimension / maxDimension
|
||||||
|
// Low aspect ratio (thin) scores high
|
||||||
|
return max(0, 1.0 - aspectRatio * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateStraightnessScore(points: [SIMD2<Float>]) -> Float {
|
||||||
|
guard points.count >= 3 else { return 1.0 }
|
||||||
|
|
||||||
|
var totalAngleChange: Float = 0
|
||||||
|
|
||||||
|
for i in 1..<(points.count - 1) {
|
||||||
|
let prev = points[i - 1]
|
||||||
|
let curr = points[i]
|
||||||
|
let next = points[i + 1]
|
||||||
|
|
||||||
|
let v1 = SIMD2<Float>(curr.x - prev.x, curr.y - prev.y)
|
||||||
|
let v2 = SIMD2<Float>(next.x - curr.x, next.y - curr.y)
|
||||||
|
|
||||||
|
let len1 = sqrt(v1.x * v1.x + v1.y * v1.y)
|
||||||
|
let len2 = sqrt(v2.x * v2.x + v2.y * v2.y)
|
||||||
|
|
||||||
|
guard len1 > 0, len2 > 0 else { continue }
|
||||||
|
|
||||||
|
let dot = (v1.x * v2.x + v1.y * v2.y) / (len1 * len2)
|
||||||
|
let angle = acos(min(1, max(-1, dot)))
|
||||||
|
totalAngleChange += angle
|
||||||
|
}
|
||||||
|
|
||||||
|
let averageAngleChange = totalAngleChange / Float(points.count - 2)
|
||||||
|
// Straight lines have low angle change
|
||||||
|
return max(0, 1.0 - averageAngleChange / .pi)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateLengthScore(points: [SIMD2<Float>]) -> Float {
|
||||||
|
let length = calculatePathLength(points: points)
|
||||||
|
// Longer contours score higher, normalized to typical wire length
|
||||||
|
return min(1.0, length * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculatePathLength(points: [SIMD2<Float>]) -> Float {
|
||||||
|
var length: Float = 0
|
||||||
|
|
||||||
|
for i in 1..<points.count {
|
||||||
|
let dx = points[i].x - points[i - 1].x
|
||||||
|
let dy = points[i].y - points[i - 1].y
|
||||||
|
length += sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
private func collectAllContours(from observation: VNContoursObservation) -> [VNContour] {
|
||||||
|
var contours: [VNContour] = []
|
||||||
|
|
||||||
|
func collect(_ contour: VNContour) {
|
||||||
|
contours.append(contour)
|
||||||
|
for i in 0..<contour.childContourCount {
|
||||||
|
if let child = try? contour.childContour(at: i) {
|
||||||
|
collect(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..<observation.contourCount {
|
||||||
|
if let contour = try? observation.contour(at: i) {
|
||||||
|
collect(contour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contours
|
||||||
|
}
|
||||||
|
}
|
||||||
275
CheapRetouch/Services/InpaintEngine/InpaintEngine.swift
Normal file
275
CheapRetouch/Services/InpaintEngine/InpaintEngine.swift
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
//
|
||||||
|
// InpaintEngine.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Exemplar-based inpainting engine using Metal.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Metal
|
||||||
|
import MetalKit
|
||||||
|
import CoreGraphics
|
||||||
|
import UIKit
|
||||||
|
import Accelerate
|
||||||
|
|
||||||
|
actor InpaintEngine {
|
||||||
|
|
||||||
|
enum InpaintError: Error, LocalizedError {
|
||||||
|
case metalNotAvailable
|
||||||
|
case deviceCreationFailed
|
||||||
|
case textureCreationFailed
|
||||||
|
case processingFailed
|
||||||
|
case memoryPressure
|
||||||
|
case invalidInput
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .metalNotAvailable:
|
||||||
|
return "Metal is not available on this device"
|
||||||
|
case .deviceCreationFailed:
|
||||||
|
return "Failed to create Metal device"
|
||||||
|
case .textureCreationFailed:
|
||||||
|
return "Failed to create texture for processing"
|
||||||
|
case .processingFailed:
|
||||||
|
return "Inpainting processing failed"
|
||||||
|
case .memoryPressure:
|
||||||
|
return "Image too large to process. Try cropping first."
|
||||||
|
case .invalidInput:
|
||||||
|
return "Invalid image or mask input"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let device: MTLDevice?
|
||||||
|
private let commandQueue: MTLCommandQueue?
|
||||||
|
private let patchRadius: Int
|
||||||
|
private let maxPreviewSize: Int = 2048
|
||||||
|
private let maxMemoryBytes: Int = 1_500_000_000 // 1.5GB
|
||||||
|
|
||||||
|
init(patchRadius: Int = 9) {
|
||||||
|
self.patchRadius = patchRadius
|
||||||
|
self.device = MTLCreateSystemDefaultDevice()
|
||||||
|
self.commandQueue = device?.makeCommandQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isMetalAvailable: Bool {
|
||||||
|
device != nil && commandQueue != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inpaint(image: CGImage, mask: CGImage) async throws -> CGImage {
|
||||||
|
// Check memory requirements
|
||||||
|
let imageMemory = image.width * image.height * 4
|
||||||
|
let maskMemory = mask.width * mask.height
|
||||||
|
let estimatedTotal = imageMemory * 3 + maskMemory * 2 // rough estimate
|
||||||
|
|
||||||
|
guard estimatedTotal < maxMemoryBytes else {
|
||||||
|
throw InpaintError.memoryPressure
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMetalAvailable {
|
||||||
|
return try await inpaintWithMetal(image: image, mask: mask, isPreview: false)
|
||||||
|
} else {
|
||||||
|
return try await inpaintWithAccelerate(image: image, mask: mask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inpaintPreview(image: CGImage, mask: CGImage) async throws -> CGImage {
|
||||||
|
// Scale down for preview if needed
|
||||||
|
let scaledImage: CGImage
|
||||||
|
let scaledMask: CGImage
|
||||||
|
|
||||||
|
if image.width > maxPreviewSize || image.height > maxPreviewSize {
|
||||||
|
let scale = CGFloat(maxPreviewSize) / CGFloat(max(image.width, image.height))
|
||||||
|
scaledImage = try scaleImage(image, scale: scale)
|
||||||
|
scaledMask = try scaleImage(mask, scale: scale)
|
||||||
|
} else {
|
||||||
|
scaledImage = image
|
||||||
|
scaledMask = mask
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMetalAvailable {
|
||||||
|
return try await inpaintWithMetal(image: scaledImage, mask: scaledMask, isPreview: true)
|
||||||
|
} else {
|
||||||
|
return try await inpaintWithAccelerate(image: scaledImage, mask: scaledMask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Metal Implementation
|
||||||
|
|
||||||
|
private func inpaintWithMetal(image: CGImage, mask: CGImage, isPreview: Bool) async throws -> CGImage {
|
||||||
|
guard let device = device, let commandQueue = commandQueue else {
|
||||||
|
throw InpaintError.metalNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, fall back to Accelerate while Metal shaders are being developed
|
||||||
|
return try await inpaintWithAccelerate(image: image, mask: mask)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Accelerate Fallback
|
||||||
|
|
||||||
|
private func inpaintWithAccelerate(image: CGImage, mask: CGImage) async throws -> CGImage {
|
||||||
|
let width = image.width
|
||||||
|
let height = image.height
|
||||||
|
|
||||||
|
guard width > 0, height > 0 else {
|
||||||
|
throw InpaintError.invalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert image to pixel buffer
|
||||||
|
guard var imageBuffer = createPixelBuffer(from: image) else {
|
||||||
|
throw InpaintError.textureCreationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert mask to grayscale buffer
|
||||||
|
guard let maskBuffer = createGrayscaleBuffer(from: mask, targetWidth: width, targetHeight: height) else {
|
||||||
|
throw InpaintError.textureCreationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple inpainting: for each masked pixel, average nearby unmasked pixels
|
||||||
|
let patchSize = patchRadius * 2 + 1
|
||||||
|
|
||||||
|
for y in 0..<height {
|
||||||
|
for x in 0..<width {
|
||||||
|
let maskIndex = y * width + x
|
||||||
|
guard maskBuffer[maskIndex] > 127 else { continue }
|
||||||
|
|
||||||
|
// This pixel needs to be filled
|
||||||
|
var totalR: Int = 0
|
||||||
|
var totalG: Int = 0
|
||||||
|
var totalB: Int = 0
|
||||||
|
var count: Int = 0
|
||||||
|
|
||||||
|
// Search in expanding rings for source pixels
|
||||||
|
for radius in 1...max(patchRadius * 4, 50) {
|
||||||
|
for dy in -radius...radius {
|
||||||
|
for dx in -radius...radius {
|
||||||
|
// Only check perimeter of current radius
|
||||||
|
guard abs(dx) == radius || abs(dy) == radius else { continue }
|
||||||
|
|
||||||
|
let sx = x + dx
|
||||||
|
let sy = y + dy
|
||||||
|
|
||||||
|
guard sx >= 0, sx < width, sy >= 0, sy < height else { continue }
|
||||||
|
|
||||||
|
let srcMaskIndex = sy * width + sx
|
||||||
|
guard maskBuffer[srcMaskIndex] <= 127 else { continue }
|
||||||
|
|
||||||
|
let srcPixelIndex = (sy * width + sx) * 4
|
||||||
|
totalR += Int(imageBuffer[srcPixelIndex])
|
||||||
|
totalG += Int(imageBuffer[srcPixelIndex + 1])
|
||||||
|
totalB += Int(imageBuffer[srcPixelIndex + 2])
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if count >= 8 { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
let pixelIndex = (y * width + x) * 4
|
||||||
|
imageBuffer[pixelIndex] = UInt8(totalR / count)
|
||||||
|
imageBuffer[pixelIndex + 1] = UInt8(totalG / count)
|
||||||
|
imageBuffer[pixelIndex + 2] = UInt8(totalB / count)
|
||||||
|
imageBuffer[pixelIndex + 3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to CGImage
|
||||||
|
guard let result = createCGImage(from: imageBuffer, width: width, height: height) else {
|
||||||
|
throw InpaintError.processingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
|
private func createPixelBuffer(from image: CGImage) -> [UInt8]? {
|
||||||
|
let width = image.width
|
||||||
|
let height = image.height
|
||||||
|
var pixelData = [UInt8](repeating: 0, count: width * height * 4)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: &pixelData,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: width * 4,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
return pixelData
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createGrayscaleBuffer(from image: CGImage, targetWidth: Int, targetHeight: Int) -> [UInt8]? {
|
||||||
|
var pixelData = [UInt8](repeating: 0, count: targetWidth * targetHeight)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: &pixelData,
|
||||||
|
width: targetWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: targetWidth,
|
||||||
|
space: CGColorSpaceCreateDeviceGray(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
context.draw(image, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight))
|
||||||
|
return pixelData
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createCGImage(from pixelData: [UInt8], width: Int, height: Int) -> CGImage? {
|
||||||
|
var mutableData = pixelData
|
||||||
|
|
||||||
|
return mutableData.withUnsafeMutableBytes { rawBuffer -> CGImage? in
|
||||||
|
guard let baseAddress = rawBuffer.baseAddress else { return nil }
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: baseAddress,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: width * 4,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.makeImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scaleImage(_ image: CGImage, scale: CGFloat) throws -> CGImage {
|
||||||
|
let newWidth = Int(CGFloat(image.width) * scale)
|
||||||
|
let newHeight = Int(CGFloat(image.height) * scale)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: newWidth * 4,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
throw InpaintError.textureCreationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
context.interpolationQuality = .high
|
||||||
|
context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight))
|
||||||
|
|
||||||
|
guard let result = context.makeImage() else {
|
||||||
|
throw InpaintError.textureCreationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
143
CheapRetouch/Services/MaskingService.swift
Normal file
143
CheapRetouch/Services/MaskingService.swift
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
//
|
||||||
|
// MaskingService.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Wrapper around Vision framework for generating masks from user taps.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Vision
|
||||||
|
import CoreImage
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
actor MaskingService {
|
||||||
|
|
||||||
|
enum MaskingError: Error, LocalizedError {
|
||||||
|
case imageConversionFailed
|
||||||
|
case requestFailed(Error)
|
||||||
|
case noMaskGenerated
|
||||||
|
case instanceNotFound
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .imageConversionFailed:
|
||||||
|
return "Failed to convert image for processing"
|
||||||
|
case .requestFailed(let error):
|
||||||
|
return "Vision request failed: \(error.localizedDescription)"
|
||||||
|
case .noMaskGenerated:
|
||||||
|
return "No mask was generated for the selected area"
|
||||||
|
case .instanceNotFound:
|
||||||
|
return "No object found at the tap location"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePersonMask(at point: CGPoint, in image: CGImage) async throws -> CGImage? {
|
||||||
|
try await generateForegroundMask(at: point, in: image)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateForegroundMask(at point: CGPoint, in image: CGImage) async throws -> CGImage? {
|
||||||
|
let request = VNGenerateForegroundInstanceMaskRequest()
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, options: [:])
|
||||||
|
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
} catch {
|
||||||
|
throw MaskingError.requestFailed(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let result = request.results?.first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize point to Vision coordinates (0-1, origin bottom-left)
|
||||||
|
let normalizedPoint = VNImagePointForNormalizedPoint(
|
||||||
|
CGPoint(x: point.x / CGFloat(image.width), y: 1.0 - point.y / CGFloat(image.height)),
|
||||||
|
image.width,
|
||||||
|
image.height
|
||||||
|
)
|
||||||
|
|
||||||
|
let visionPoint = CGPoint(
|
||||||
|
x: point.x / CGFloat(image.width),
|
||||||
|
y: 1.0 - point.y / CGFloat(image.height)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find instance at tap point
|
||||||
|
let instances = result.allInstances
|
||||||
|
var targetInstance: IndexSet?
|
||||||
|
|
||||||
|
for instance in instances {
|
||||||
|
let indexSet = IndexSet(integer: instance)
|
||||||
|
if let maskPixelBuffer = try? result.generateScaledMaskForImage(forInstances: indexSet, from: handler) {
|
||||||
|
// Check if point is within this instance's mask
|
||||||
|
if isPoint(visionPoint, inMask: maskPixelBuffer, imageSize: CGSize(width: image.width, height: image.height)) {
|
||||||
|
targetInstance = indexSet
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let instance = targetInstance else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let maskPixelBuffer = try result.generateScaledMaskForImage(forInstances: instance, from: handler)
|
||||||
|
return convertPixelBufferToCGImage(maskPixelBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAllForegroundMasks(in image: CGImage) async throws -> CGImage? {
|
||||||
|
let request = VNGenerateForegroundInstanceMaskRequest()
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(cgImage: image, options: [:])
|
||||||
|
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
} catch {
|
||||||
|
throw MaskingError.requestFailed(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let result = request.results?.first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let allInstances = result.allInstances
|
||||||
|
guard !allInstances.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let maskPixelBuffer = try result.generateScaledMaskForImage(forInstances: IndexSet(allInstances), from: handler)
|
||||||
|
return convertPixelBufferToCGImage(maskPixelBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isPoint(_ point: CGPoint, inMask pixelBuffer: CVPixelBuffer, imageSize: CGSize) -> Bool {
|
||||||
|
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
||||||
|
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
|
||||||
|
|
||||||
|
let width = CVPixelBufferGetWidth(pixelBuffer)
|
||||||
|
let height = CVPixelBufferGetHeight(pixelBuffer)
|
||||||
|
|
||||||
|
let x = Int(point.x * CGFloat(width))
|
||||||
|
let y = Int((1.0 - point.y) * CGFloat(height))
|
||||||
|
|
||||||
|
guard x >= 0, x < width, y >= 0, y < height else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
||||||
|
let pixelOffset = y * bytesPerRow + x
|
||||||
|
|
||||||
|
let pixelValue = baseAddress.load(fromByteOffset: pixelOffset, as: UInt8.self)
|
||||||
|
return pixelValue > 127
|
||||||
|
}
|
||||||
|
|
||||||
|
private func convertPixelBufferToCGImage(_ pixelBuffer: CVPixelBuffer) -> CGImage? {
|
||||||
|
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
|
||||||
|
let context = CIContext()
|
||||||
|
return context.createCGImage(ciImage, from: ciImage.extent)
|
||||||
|
}
|
||||||
|
}
|
||||||
192
CheapRetouch/Utilities/EdgeRefinement.swift
Normal file
192
CheapRetouch/Utilities/EdgeRefinement.swift
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
//
|
||||||
|
// EdgeRefinement.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Smart brush edge detection using gradient magnitude analysis.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
import Accelerate
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct EdgeRefinement {
|
||||||
|
|
||||||
|
struct GradientImage {
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
let magnitude: [Float]
|
||||||
|
let directionX: [Float]
|
||||||
|
let directionY: [Float]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func computeGradient(from image: CGImage) -> GradientImage? {
|
||||||
|
let width = image.width
|
||||||
|
let height = image.height
|
||||||
|
|
||||||
|
// Convert to grayscale
|
||||||
|
guard let grayscale = convertToGrayscale(image) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute Sobel gradients
|
||||||
|
var gradientX = [Float](repeating: 0, count: width * height)
|
||||||
|
var gradientY = [Float](repeating: 0, count: width * height)
|
||||||
|
var magnitude = [Float](repeating: 0, count: width * height)
|
||||||
|
|
||||||
|
// Sobel kernels
|
||||||
|
let sobelX: [Int16] = [-1, 0, 1, -2, 0, 2, -1, 0, 1]
|
||||||
|
let sobelY: [Int16] = [-1, -2, -1, 0, 0, 0, 1, 2, 1]
|
||||||
|
|
||||||
|
var sourceBuffer = vImage_Buffer(
|
||||||
|
data: UnsafeMutableRawPointer(mutating: grayscale),
|
||||||
|
height: vImagePixelCount(height),
|
||||||
|
width: vImagePixelCount(width),
|
||||||
|
rowBytes: width
|
||||||
|
)
|
||||||
|
|
||||||
|
var destBufferX = vImage_Buffer(
|
||||||
|
data: &gradientX,
|
||||||
|
height: vImagePixelCount(height),
|
||||||
|
width: vImagePixelCount(width),
|
||||||
|
rowBytes: width * MemoryLayout<Float>.size
|
||||||
|
)
|
||||||
|
|
||||||
|
var destBufferY = vImage_Buffer(
|
||||||
|
data: &gradientY,
|
||||||
|
height: vImagePixelCount(height),
|
||||||
|
width: vImagePixelCount(width),
|
||||||
|
rowBytes: width * MemoryLayout<Float>.size
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply Sobel filters (simplified - using direct calculation)
|
||||||
|
for y in 1..<(height - 1) {
|
||||||
|
for x in 1..<(width - 1) {
|
||||||
|
var gx: Float = 0
|
||||||
|
var gy: Float = 0
|
||||||
|
|
||||||
|
for ky in -1...1 {
|
||||||
|
for kx in -1...1 {
|
||||||
|
let pixel = Float(grayscale[(y + ky) * width + (x + kx)])
|
||||||
|
let kernelIndex = (ky + 1) * 3 + (kx + 1)
|
||||||
|
gx += pixel * Float(sobelX[kernelIndex])
|
||||||
|
gy += pixel * Float(sobelY[kernelIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = y * width + x
|
||||||
|
gradientX[index] = gx
|
||||||
|
gradientY[index] = gy
|
||||||
|
magnitude[index] = sqrt(gx * gx + gy * gy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GradientImage(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
magnitude: magnitude,
|
||||||
|
directionX: gradientX,
|
||||||
|
directionY: gradientY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func refineSelectionToEdges(
|
||||||
|
selection: [CGPoint],
|
||||||
|
gradient: GradientImage,
|
||||||
|
searchRadius: Int = 5
|
||||||
|
) -> [CGPoint] {
|
||||||
|
var refinedPoints: [CGPoint] = []
|
||||||
|
|
||||||
|
for point in selection {
|
||||||
|
let x = Int(point.x)
|
||||||
|
let y = Int(point.y)
|
||||||
|
|
||||||
|
// Find the nearby point with highest gradient magnitude
|
||||||
|
var maxMagnitude: Float = 0
|
||||||
|
var bestX = x
|
||||||
|
var bestY = y
|
||||||
|
|
||||||
|
for dy in -searchRadius...searchRadius {
|
||||||
|
for dx in -searchRadius...searchRadius {
|
||||||
|
let nx = x + dx
|
||||||
|
let ny = y + dy
|
||||||
|
|
||||||
|
guard nx >= 0, nx < gradient.width,
|
||||||
|
ny >= 0, ny < gradient.height else { continue }
|
||||||
|
|
||||||
|
let index = ny * gradient.width + nx
|
||||||
|
let mag = gradient.magnitude[index]
|
||||||
|
|
||||||
|
if mag > maxMagnitude {
|
||||||
|
maxMagnitude = mag
|
||||||
|
bestX = nx
|
||||||
|
bestY = ny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refinedPoints.append(CGPoint(x: bestX, y: bestY))
|
||||||
|
}
|
||||||
|
|
||||||
|
return refinedPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createMaskFromPoints(
|
||||||
|
_ points: [CGPoint],
|
||||||
|
brushSize: CGFloat,
|
||||||
|
imageSize: CGSize
|
||||||
|
) -> CGImage? {
|
||||||
|
let width = Int(imageSize.width)
|
||||||
|
let height = Int(imageSize.height)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: width,
|
||||||
|
space: CGColorSpaceCreateDeviceGray(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setFillColor(gray: 0, alpha: 1)
|
||||||
|
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
|
||||||
|
context.setFillColor(gray: 1, alpha: 1)
|
||||||
|
|
||||||
|
for point in points {
|
||||||
|
let rect = CGRect(
|
||||||
|
x: point.x - brushSize / 2,
|
||||||
|
y: point.y - brushSize / 2,
|
||||||
|
width: brushSize,
|
||||||
|
height: brushSize
|
||||||
|
)
|
||||||
|
context.fillEllipse(in: rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.makeImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func convertToGrayscale(_ image: CGImage) -> [UInt8]? {
|
||||||
|
let width = image.width
|
||||||
|
let height = image.height
|
||||||
|
var pixelData = [UInt8](repeating: 0, count: width * height)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: &pixelData,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: width,
|
||||||
|
space: CGColorSpaceCreateDeviceGray(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.none.rawValue
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||||
|
return pixelData
|
||||||
|
}
|
||||||
|
}
|
||||||
103
CheapRetouch/Utilities/ImagePipeline.swift
Normal file
103
CheapRetouch/Utilities/ImagePipeline.swift
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//
|
||||||
|
// ImagePipeline.swift
|
||||||
|
// CheapRetouch
|
||||||
|
//
|
||||||
|
// Preview and export rendering pipeline.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreImage
|
||||||
|
import CoreGraphics
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
actor ImagePipeline {
|
||||||
|
|
||||||
|
private let context: CIContext
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.context = CIContext(options: [
|
||||||
|
.useSoftwareRenderer: false,
|
||||||
|
.highQualityDownsample: true
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPreview(
|
||||||
|
originalImage: CGImage,
|
||||||
|
operations: [EditOperation],
|
||||||
|
maxSize: Int = 2048
|
||||||
|
) async throws -> CGImage {
|
||||||
|
// Scale down for preview if needed
|
||||||
|
var image = originalImage
|
||||||
|
|
||||||
|
if image.width > maxSize || image.height > maxSize {
|
||||||
|
let scale = CGFloat(maxSize) / CGFloat(max(image.width, image.height))
|
||||||
|
image = try scaleImage(image, scale: scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply operations (placeholder for now)
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderExport(
|
||||||
|
originalImage: CGImage,
|
||||||
|
operations: [EditOperation]
|
||||||
|
) async throws -> CGImage {
|
||||||
|
// Apply operations at full resolution
|
||||||
|
return originalImage
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyColorAdjustment(
|
||||||
|
to image: CGImage,
|
||||||
|
brightness: Float = 0,
|
||||||
|
contrast: Float = 1,
|
||||||
|
saturation: Float = 1
|
||||||
|
) -> CGImage? {
|
||||||
|
let ciImage = CIImage(cgImage: image)
|
||||||
|
|
||||||
|
guard let filter = CIFilter(name: "CIColorControls") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filter.setValue(ciImage, forKey: kCIInputImageKey)
|
||||||
|
filter.setValue(brightness, forKey: kCIInputBrightnessKey)
|
||||||
|
filter.setValue(contrast, forKey: kCIInputContrastKey)
|
||||||
|
filter.setValue(saturation, forKey: kCIInputSaturationKey)
|
||||||
|
|
||||||
|
guard let outputImage = filter.outputImage else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.createCGImage(outputImage, from: outputImage.extent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scaleImage(_ image: CGImage, scale: CGFloat) throws -> CGImage {
|
||||||
|
let newWidth = Int(CGFloat(image.width) * scale)
|
||||||
|
let newHeight = Int(CGFloat(image.height) * scale)
|
||||||
|
|
||||||
|
guard let context = CGContext(
|
||||||
|
data: nil,
|
||||||
|
width: newWidth,
|
||||||
|
height: newHeight,
|
||||||
|
bitsPerComponent: 8,
|
||||||
|
bytesPerRow: newWidth * 4,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(),
|
||||||
|
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||||
|
) else {
|
||||||
|
throw ImagePipelineError.contextCreationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
context.interpolationQuality = .high
|
||||||
|
context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight))
|
||||||
|
|
||||||
|
guard let result = context.makeImage() else {
|
||||||
|
throw ImagePipelineError.renderFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ImagePipelineError: Error {
|
||||||
|
case contextCreationFailed
|
||||||
|
case renderFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
26
check_build.sh
Executable file
26
check_build.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
set -o pipefail # Fail if xcodebuild fails, even with xcbeautify
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
SCHEME="CheapRetouch"
|
||||||
|
BUILD_PATH="./build"
|
||||||
|
|
||||||
|
echo "🔍 Checking compilation for $SCHEME..."
|
||||||
|
|
||||||
|
# Build Only (No Install/Launch)
|
||||||
|
# We use 'env -u' to hide Homebrew variables
|
||||||
|
# We use '-derivedDataPath' to keep it isolated
|
||||||
|
env -u CC -u CXX -u LIBCLANG_PATH xcodebuild \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-destination "platform=iOS Simulator,name=iPhone 17 Pro" \
|
||||||
|
-configuration Debug \
|
||||||
|
-derivedDataPath "$BUILD_PATH" \
|
||||||
|
build | xcbeautify
|
||||||
|
|
||||||
|
# Check exit code of the pipeline
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Build Succeeded. No errors found."
|
||||||
|
else
|
||||||
|
echo "❌ Build Failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user