diff --git a/kplayer.xcodeproj/project.pbxproj b/kplayer.xcodeproj/project.pbxproj index 6431f9c..b70382b 100644 --- a/kplayer.xcodeproj/project.pbxproj +++ b/kplayer.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ 1C736F6A223D4ADB2E1BA733 /* ItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C736069C214E9522BB1BD97 /* ItemCell.swift */; }; 1C736F7D29B76C7037CEF778 /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C73647019E6C2E822127BA3 /* DatabaseManager.swift */; }; 1C736FAE5D3E5D3BA3C1FAE5 /* links.html in Resources */ = {isa = PBXBuildFile; fileRef = 1C73645DBD6499A726D34973 /* links.html */; }; + 1C736FB6920008B2603D3415 /* openapi.json in Resources */ = {isa = PBXBuildFile; fileRef = 1C736DD5585DB81208FBF425 /* openapi.json */; }; 1C736FB92B19FE17E4357C85 /* MediaItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C73688DAB88F9360FB62A49 /* MediaItem.swift */; }; AA74B07A01F0E99E6DEC7D1B /* Pods_kplayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B75159FFCD5A882E6F167FE /* Pods_kplayer.framework */; }; C91E05892795AC5C0003AB79 /* KTag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C91E05832795AC5C0003AB79 /* KTag+CoreDataClass.swift */; }; @@ -88,6 +89,7 @@ C91E058C2795AC5C0003AB79 /* KItem+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C91E05862795AC5C0003AB79 /* KItem+CoreDataProperties.swift */; }; C91E058D2795AC5C0003AB79 /* KSnapshot+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C91E05872795AC5C0003AB79 /* KSnapshot+CoreDataClass.swift */; }; C91E058E2795AC5C0003AB79 /* KSnapshot+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C91E05882795AC5C0003AB79 /* KSnapshot+CoreDataProperties.swift */; }; + C92A39432EE783930013E899 /* FaceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92A39422EE7838B0013E899 /* FaceManager.swift */; }; C98AF5D51B124D6A00D196CC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98AF5D41B124D6A00D196CC /* AppDelegate.swift */; }; C98AF5D81B124D6A00D196CC /* kplayer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C98AF5D61B124D6A00D196CC /* kplayer.xcdatamodeld */; }; C98AF5DF1B124D6A00D196CC /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C98AF5DD1B124D6A00D196CC /* Main.storyboard */; }; @@ -175,6 +177,7 @@ 1C736DBB6986A8B62963FBB3 /* HtmlParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HtmlParser.swift; sourceTree = ""; }; 1C736DCCE3AA9993E15F7652 /* UIImageExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIImageExtension.swift; sourceTree = ""; }; 1C736DCD945ABAE984FF43EF /* KNetworkProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KNetworkProtocol.swift; sourceTree = ""; }; + 1C736DD5585DB81208FBF425 /* openapi.json */ = {isa = PBXFileReference; lastKnownFileType = file.json; path = openapi.json; sourceTree = ""; }; 1C736E32C8574BFE3536F1C2 /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 1C736E59A0C8DB9AC4D98F7B /* SRangeSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SRangeSlider.swift; sourceTree = ""; }; 1C736EA15A11AF7D57F85824 /* ThumbnailCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThumbnailCache.swift; sourceTree = ""; }; @@ -191,6 +194,7 @@ C91E05862795AC5C0003AB79 /* KItem+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KItem+CoreDataProperties.swift"; sourceTree = ""; }; C91E05872795AC5C0003AB79 /* KSnapshot+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KSnapshot+CoreDataClass.swift"; sourceTree = ""; }; C91E05882795AC5C0003AB79 /* KSnapshot+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KSnapshot+CoreDataProperties.swift"; sourceTree = ""; }; + C92A39422EE7838B0013E899 /* FaceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceManager.swift; sourceTree = ""; }; C98AF5CF1B124D6A00D196CC /* kplayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = kplayer.app; sourceTree = BUILT_PRODUCTS_DIR; }; C98AF5D31B124D6A00D196CC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C98AF5D41B124D6A00D196CC /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -311,6 +315,7 @@ 1C736DC8C3AFB991541A2079 /* core */ = { isa = PBXGroup; children = ( + C92A39422EE7838B0013E899 /* FaceManager.swift */, C9D6764E291E73A00060179C /* Cached+CoreDataClass.swift */, C9D6764F291E73A00060179C /* Cached+CoreDataProperties.swift */, C91E05832795AC5C0003AB79 /* KTag+CoreDataClass.swift */, @@ -331,6 +336,7 @@ 1C73659CC9B523B957E58DC6 /* LocalManager.swift */, 1C73647019E6C2E822127BA3 /* DatabaseManager.swift */, 1C7363743CD8120637AC1EDE /* KFrame.swift */, + 1C736DD5585DB81208FBF425 /* openapi.json */, ); path = core; sourceTree = ""; @@ -545,6 +551,7 @@ 1C73696E4C0353053BF98031 /* links.html in Resources */, 1C736FAE5D3E5D3BA3C1FAE5 /* links.html in Resources */, 1C736C9821DA743C2E3F3B07 /* kplayer.txt in Resources */, + 1C736FB6920008B2603D3415 /* openapi.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -629,6 +636,7 @@ 1C736821D6DF2237A3EABCC1 /* ViewControllerExtensions.swift in Sources */, 1C73671FC2CCCACAA2FFC153 /* ThumbnailCache.swift in Sources */, C91E05892795AC5C0003AB79 /* KTag+CoreDataClass.swift in Sources */, + C92A39432EE783930013E899 /* FaceManager.swift in Sources */, 1C736E21B246C0BE7E123FD3 /* MediaModel.swift in Sources */, 1C736A7B6221A1D50FB3904C /* ItemType.swift in Sources */, 1C7360C0F2A4F0214FE353BD /* FileHelper.swift in Sources */, diff --git a/kplayer/core/FaceManager.swift b/kplayer/core/FaceManager.swift new file mode 100644 index 0000000..d118026 --- /dev/null +++ b/kplayer/core/FaceManager.swift @@ -0,0 +1,306 @@ +// +// Created by Marco Schmickler on 08.12.25. +// Copyright (c) 2025 Marco Schmickler. All rights reserved. +// + +import Foundation + +// MARK: - Error Types +enum FaceAPIError: Error, LocalizedError { + case invalidURL + case networkError(Error) + case invalidResponse + case decodingError(Error) + case validationError(String) + case serverError(String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .invalidResponse: + return "Invalid response from server" + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .validationError(let message): + return "Validation error: \(message)" + case .serverError(let message): + return "Server error: \(message)" + } + } +} + +// MARK: - Request Models +struct ProcessImageRequest: Codable { + let inputImagePath: String + let sourceFacePath: String + let outputPath: String? + + enum CodingKeys: String, CodingKey { + case inputImagePath = "input_image_path" + case sourceFacePath = "source_face_path" + case outputPath = "output_path" + } +} + +struct ProcessFolderRequest: Codable { + let inputFolderPath: String + let sourceFacePath: String + let outputFolderPath: String? + + enum CodingKeys: String, CodingKey { + case inputFolderPath = "input_folder_path" + case sourceFacePath = "source_face_path" + case outputFolderPath = "output_folder_path" + } +} + +struct ProcessVideoRequest: Codable { + let inputVideoPath: String + let sourceFacePath: String + let outputPath: String? + + enum CodingKeys: String, CodingKey { + case inputVideoPath = "input_video_path" + case sourceFacePath = "source_face_path" + case outputPath = "output_path" + } +} + +// MARK: - Response Models +struct ProcessResponse: Codable { + let success: Bool + let message: String + let outputPath: String? + let processingTime: Double? + + enum CodingKeys: String, CodingKey { + case success + case message + case outputPath = "output_path" + case processingTime = "processing_time" + } +} + +struct FolderProcessResponse: Codable { + let success: Bool + let message: String + let outputFolder: String + let totalImages: Int + let successCount: Int + let failedCount: Int + let processingTime: Double + + enum CodingKeys: String, CodingKey { + case success + case message + case outputFolder = "output_folder" + case totalImages = "total_images" + case successCount = "success_count" + case failedCount = "failed_count" + case processingTime = "processing_time" + } +} + +struct HealthResponse: Codable { + let status: String + let version: String + let cacheStats: [String: AnyCodable] + + enum CodingKeys: String, CodingKey { + case status + case version + case cacheStats = "cache_stats" + } +} + +struct ClearCacheResponse: Codable { + let message: String? +} + +struct CacheStatsResponse: Codable { + let stats: [String: AnyCodable] +} + +// Helper for dynamic JSON values +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues { $0.value } + } else { + value = NSNull() + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let bool as Bool: + try container.encode(bool) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dict as [String: Any]: + try container.encode(dict.mapValues { AnyCodable($0) }) + default: + try container.encodeNil() + } + } +} + +// MARK: - API Client +class FaceManager { + static let sharedInstance = FaceManager() + + let faceUrl = "http://win11marco:5013" + private let session: URLSession + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 300 + config.timeoutIntervalForResource = 600 + self.session = URLSession(configuration: config) + } + + // MARK: - Private Helper Methods + private func createRequest(endpoint: String, method: String = "GET", body: Codable? = nil) throws -> URLRequest { + guard let url = URL(string: faceUrl + endpoint) else { + throw FaceAPIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let body = body { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + request.httpBody = try encoder.encode(body) + } + + return request + } + + private func executeRequest(_ request: URLRequest) async throws -> T { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw FaceAPIError.invalidResponse + } + + guard (200...299).contains(httpResponse.statusCode) else { + if let errorMessage = try? JSONDecoder().decode([String: String].self, from: data), + let detail = errorMessage["detail"] { + throw FaceAPIError.serverError(detail) + } + throw FaceAPIError.serverError("HTTP \(httpResponse.statusCode)") + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + do { + return try decoder.decode(T.self, from: data) + } catch { + throw FaceAPIError.decodingError(error) + } + } + + // MARK: - API Methods + + /// Get API information and documentation + func getRoot() async throws -> [String: Any] { + let request = try createRequest(endpoint: "/") + let response: [String: AnyCodable] = try await executeRequest(request) + return response.mapValues { $0.value } + } + + /// Health check endpoint with cache statistics + func healthCheck() async throws -> HealthResponse { + let request = try createRequest(endpoint: "/health") + return try await executeRequest(request) + } + + /// Process a single image with face swapping and GFPGAN restoration + /// - Parameters: + /// - inputImagePath: Path to the image to process + /// - sourceFacePath: Path to the source face image + /// - outputPath: Optional path for output (default: output.jpg) + func processImage(inputImagePath: String, sourceFacePath: String, outputPath: String? = nil) async throws -> ProcessResponse { + let body = ProcessImageRequest( + inputImagePath: inputImagePath, + sourceFacePath: sourceFacePath, + outputPath: outputPath + ) + let request = try createRequest(endpoint: "/process-image", method: "POST", body: body) + return try await executeRequest(request) + } + + /// Process all images in a folder with face swapping and GFPGAN restoration + /// - Parameters: + /// - inputFolderPath: Path to folder containing images + /// - sourceFacePath: Path to the source face image + /// - outputFolderPath: Optional path to output folder (default: /swap) + func processFolder(inputFolderPath: String, sourceFacePath: String, outputFolderPath: String? = nil) async throws -> FolderProcessResponse { + let body = ProcessFolderRequest( + inputFolderPath: inputFolderPath, + sourceFacePath: sourceFacePath, + outputFolderPath: outputFolderPath + ) + let request = try createRequest(endpoint: "/process-folder", method: "POST", body: body) + return try await executeRequest(request) + } + + /// Process a video file with face swapping and GFPGAN restoration + /// - Parameters: + /// - inputVideoPath: Path to the video to process + /// - sourceFacePath: Path to the source face image + /// - outputPath: Optional path for output video (default: output.mp4) + func processVideo(inputVideoPath: String, sourceFacePath: String, outputPath: String? = nil) async throws -> ProcessResponse { + let body = ProcessVideoRequest( + inputVideoPath: inputVideoPath, + sourceFacePath: sourceFacePath, + outputPath: outputPath + ) + let request = try createRequest(endpoint: "/process-video", method: "POST", body: body) + return try await executeRequest(request) + } + + /// Clear the model cache + func clearCache() async throws -> [String: Any] { + let request = try createRequest(endpoint: "/cache/clear", method: "POST") + let response: [String: AnyCodable] = try await executeRequest(request) + return response.mapValues { $0.value } + } + + /// Get detailed cache statistics + func getCacheStats() async throws -> [String: Any] { + let request = try createRequest(endpoint: "/cache/stats") + let response: [String: AnyCodable] = try await executeRequest(request) + return response.mapValues { $0.value } + } +} diff --git a/kplayer/photo/SPhotoAlbumView.swift b/kplayer/photo/SPhotoAlbumView.swift index d8728b3..22b1d7f 100644 --- a/kplayer/photo/SPhotoAlbumView.swift +++ b/kplayer/photo/SPhotoAlbumView.swift @@ -69,7 +69,7 @@ struct SPhotoAlbumView: View { if embedded && !more { v.overlay(SEmbeddedVideo(embedded: $embedded, down: $embDown).offset(y: embDown ? 0: 70), alignment: embDown ? .bottomLeading : .topLeading) } else if more { - v.overlay(VStack { + v.overlay(VStack (spacing: 15 ) { KToggleButton(text: "spring", binding: $model.spring).frame(height: 30) Button(action: { saveSelectedItem() @@ -88,8 +88,17 @@ struct SPhotoAlbumView: View { Text("copy") }) .buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("sarah")}, label: {Text("sarah")}).buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("claudia")}, label: {Text("claudia")}).buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("jessica")}, label: {Text("jessica")}).buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("marleen")}, label: {Text("marleen")}).buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("renate")}, label: {Text("renate")}).buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("birgit")}, label: {Text("birgit")}).buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("barbara")}, label: {Text("barbara")}).buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("nina")}, label: {Text("nina")}).buttonStyle(BorderlessButtonStyle()) + Button(action: {faceSelectedItem("amruta")}, label: {Text("amruta")}).buttonStyle(BorderlessButtonStyle()) } - .frame(width: 60, alignment: .top).offset(x: 0, y: 70), alignment: .topLeading) + .frame(width: 80, alignment: .top).offset(x: 0, y: 70), alignment: .topLeading) .overlay(TagEditor(item: model.allItems[model.index], completionHandler: DatabaseManager.sharedInstance.saveItemMetaData) .frame(width: 60, alignment: .top).offset(x: 0, y: 70), alignment: .topTrailing) @@ -105,6 +114,22 @@ struct SPhotoAlbumView: View { DatabaseManager.sharedInstance.saveItemMetaData(item) } + func faceSelectedItem(_ name: String) { + let item = model.selectedItem + let path = item.fullPath.replacing("/srv/samba/ren", with: "z:") + let outpath1 = path.replacing("/", with: "") + let outpath = outpath1.replacing("z:", with: "z:/cut/"+name+"/") + + print(path) + print(outpath1) + print(outpath) + + Task { + try await FaceManager.sharedInstance.processFolder(inputFolderPath: path, sourceFacePath: "benchmark/"+name+".jpg", outputFolderPath: outpath) + //try! await FaceManager.sharedInstance.processImage(inputImagePath: "input", sourceFacePath: "benchmark/Renate.jpg") + } + } + func cleanup() { for i in model.allItems { i.thumbImage = nil