新增ai修图
This commit is contained in:
@ -0,0 +1,308 @@
|
||||
//
|
||||
// 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,
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user