// // TravelAlbumAIEditResultStore.swift // suixinkan // import CoreImage import UIKit /// AI 修图结果的内存数据源,在相册管理与照片预览之间共享本地演示结果。 @MainActor final class TravelAlbumAIEditResultStore { /// 单张照片的修图记录。 struct Record { var state: TravelAlbumAIEditResultState var editedImage: UIImage? var atmosphereImage: UIImage? var coverImage: UIImage? } static let shared = TravelAlbumAIEditResultStore() static let didChangeNotification = Notification.Name("TravelAlbumAIEditResultStore.didChange") static let materialIDUserInfoKey = "materialID" private var recordsByMaterialID: [Int: Record] = [:] private init() {} /// 获取指定照片的修图状态。 func state(for materialID: Int) -> TravelAlbumAIEditResultState { recordsByMaterialID[materialID]?.state ?? TravelAlbumAIEditResultState() } /// 获取指定照片最新的修图结果。 func editedImage(for materialID: Int) -> UIImage? { recordsByMaterialID[materialID]?.editedImage } /// 获取指定照片最新的氛围感修图结果。 func atmosphereImage(for materialID: Int) -> UIImage? { recordsByMaterialID[materialID]?.atmosphereImage } /// 获取指定照片最新的封面模板结果。 func coverImage(for materialID: Int) -> UIImage? { recordsByMaterialID[materialID]?.coverImage } /// 将照片标记为修图中,已有结果继续保留到新结果完成。 func startProcessing(materialIDs: [Int]) { materialIDs.forEach { materialID in var record = recordsByMaterialID[materialID] ?? Record( state: TravelAlbumAIEditResultState(), editedImage: nil, atmosphereImage: nil, coverImage: nil ) record.state.startProcessing() recordsByMaterialID[materialID] = record notifyChange(materialID: materialID) } } /// 保存最新结果并覆盖同一照片之前的修图结果。 func complete( materialID: Int, presetID: String, atmosphereOptionID: String? = nil, coverTemplateID: String? = nil, editedImage: UIImage, atmosphereImage: UIImage? = nil, coverImage: UIImage? = nil ) { var record = recordsByMaterialID[materialID] ?? Record( state: TravelAlbumAIEditResultState(), editedImage: nil, atmosphereImage: nil, coverImage: nil ) record.state.complete( presetID: presetID, atmosphereOptionID: atmosphereOptionID, coverTemplateID: coverTemplateID ) record.editedImage = editedImage record.atmosphereImage = atmosphereImage record.coverImage = coverImage recordsByMaterialID[materialID] = record notifyChange(materialID: materialID) } /// 修图失败时恢复按钮可用状态,并保留之前成功的结果。 func failProcessing(materialID: Int) { guard var record = recordsByMaterialID[materialID] else { return } record.state.failProcessing() recordsByMaterialID[materialID] = record notifyChange(materialID: materialID) } /// 还原为原图时清除该照片的本地修图结果。 func restoreOriginal(materialID: Int) { guard var record = recordsByMaterialID[materialID] else { return } record.state.restoreOriginal() record.editedImage = nil record.atmosphereImage = nil record.coverImage = nil recordsByMaterialID[materialID] = record notifyChange(materialID: materialID) } /// 切换指定照片当前展示的原图或修图后版本。 func selectDisplayMode(_ mode: TravelAlbumAIEditResultState.DisplayMode, materialID: Int) { guard var record = recordsByMaterialID[materialID] else { return } record.state.selectDisplayMode(mode) recordsByMaterialID[materialID] = record notifyChange(materialID: materialID) } /// 删除照片时同步清理其本地修图结果。 func remove(materialID: Int) { recordsByMaterialID.removeValue(forKey: materialID) notifyChange(materialID: materialID) } private func notifyChange(materialID: Int) { NotificationCenter.default.post( name: Self.didChangeNotification, object: self, userInfo: [Self.materialIDUserInfoKey: materialID] ) } } /// 本地 AI 修图效果生成器;后续接入服务端时可替换为结果图片下载。 @MainActor enum TravelAlbumAIEditImageProcessor { private static let context = CIContext() /// 根据预设生成演示用修图图片。 static func render( effect: TravelAlbumEditPreset.Effect, source: UIImage ) -> UIImage { guard effect != .original, let ciImage = CIImage(image: source) else { return source } let output: CIImage switch effect { case .original: output = ciImage case .portrait: output = ciImage.applyingFilter("CIColorControls", parameters: [ kCIInputSaturationKey: 0.86, kCIInputBrightnessKey: 0.07, kCIInputContrastKey: 1.08, ]) case .vintage: output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.45]) case .brocade: output = ciImage.applyingFilter("CIColorControls", parameters: [ kCIInputSaturationKey: 1.22, kCIInputBrightnessKey: 0.04, kCIInputContrastKey: 1.1, ]) case .distantMountain: output = ciImage.applyingFilter("CIColorControls", parameters: [ kCIInputSaturationKey: 0.72, kCIInputBrightnessKey: 0.1, kCIInputContrastKey: 0.9, ]) case .mist: output = ciImage.applyingFilter("CIColorControls", parameters: [ kCIInputSaturationKey: 0.7, kCIInputBrightnessKey: 0.14, kCIInputContrastKey: 0.82, ]) case .summer: output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.24]) case .rich: output = ciImage.applyingFilter("CIColorControls", parameters: [ kCIInputSaturationKey: 1.38, kCIInputBrightnessKey: -0.02, kCIInputContrastKey: 1.2, ]) } guard let cgImage = context.createCGImage(output, from: output.extent) else { return source } return UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation) } } /// 本地封面模板效果生成器,让封面结果在缩略图和预览页中与普通照片明显区分。 @MainActor enum TravelAlbumCoverTemplateImageProcessor { /// 将照片渲染为指定封面排版;无原图时使用稳定的演示背景。 static func render( template: TravelAlbumCoverTemplate, source: UIImage?, targetSize: CGSize = CGSize(width: 1200, height: 900) ) -> UIImage { UIGraphicsImageRenderer(size: targetSize).image { context in let bounds = CGRect(origin: .zero, size: targetSize) UIColor(hex: 0xCAD8E8).setFill() context.cgContext.fill(bounds) if let source { source.draw(in: aspectFillRect(imageSize: source.size, bounds: bounds)) } let unit = min(targetSize.width / 232, targetSize.height / 208) switch template.previewStyle { case .travelMemoir: UIColor.black.withAlphaComponent(0.24).setFill() context.cgContext.fill(bounds) UIColor.white.setStroke() let oval = UIBezierPath(ovalIn: CGRect( x: bounds.midX - 50 * unit, y: 28 * unit, width: 100 * unit, height: 100 * unit )) oval.lineWidth = 9 * unit oval.stroke() drawCaption( "TRAVEL\nMEMOIR", in: CGRect(x: 30 * unit, y: bounds.height - 62 * unit, width: bounds.width - 60 * unit, height: 46 * unit), alignment: .center, fontSize: 17 * unit ) case .minimal: UIColor.white.withAlphaComponent(0.9).setFill() context.cgContext.fill(CGRect(x: bounds.width * 0.55, y: 0, width: bounds.width * 0.45, height: bounds.height)) drawCaption( "LESS\nIS MORE", in: CGRect(x: bounds.width * 0.62, y: bounds.midY - 27 * unit, width: bounds.width * 0.3, height: 54 * unit), alignment: .left, color: .black, fontSize: 17 * unit ) case .scenicStory: UIColor.black.withAlphaComponent(0.2).setFill() context.cgContext.fill(bounds) UIColor.white.setStroke() let inset = 20 * unit let frame = UIBezierPath(rect: bounds.insetBy(dx: inset, dy: inset)) frame.lineWidth = 4 * unit frame.stroke() drawCaption( "SCENIC STORY", in: CGRect(x: 34 * unit, y: bounds.height - 58 * unit, width: bounds.width - 68 * unit, height: 24 * unit), alignment: .center, fontSize: 17 * unit ) case .film: let stripHeight = 24 * unit UIColor.black.withAlphaComponent(0.72).setFill() context.cgContext.fill(CGRect(x: 0, y: 0, width: bounds.width, height: stripHeight)) context.cgContext.fill(CGRect(x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight)) UIColor.white.withAlphaComponent(0.9).setFill() var x = 10 * unit while x <= bounds.width - 16 * unit { context.cgContext.fill(CGRect(x: x, y: 6 * unit, width: 14 * unit, height: 10 * unit)) context.cgContext.fill(CGRect( x: x, y: bounds.height - 16 * unit, width: 14 * unit, height: 10 * unit )) x += 30 * unit } drawCaption( "THE JOURNEY", in: CGRect(x: 28 * unit, y: bounds.height - 60 * unit, width: bounds.width - 56 * unit, height: 24 * unit), alignment: .center, fontSize: 17 * unit ) } } } private static func aspectFillRect(imageSize: CGSize, bounds: CGRect) -> CGRect { guard imageSize.width > 0, imageSize.height > 0 else { return bounds } let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height) let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale) return CGRect( x: bounds.midX - drawSize.width / 2, y: bounds.midY - drawSize.height / 2, width: drawSize.width, height: drawSize.height ) } private static func drawCaption( _ text: String, in rect: CGRect, alignment: NSTextAlignment, color: UIColor = .white, fontSize: CGFloat ) { let paragraph = NSMutableParagraphStyle() paragraph.alignment = alignment text.draw( in: rect, withAttributes: [ .font: UIFont.systemFont(ofSize: fontSize, weight: .bold), .foregroundColor: color, .paragraphStyle: paragraph, ] ) } }