feat: add AI retouch before-after comparison
This commit is contained in:
@@ -117,6 +117,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"id": "tpl_refined_12",
|
||||
"name": "清透精修",
|
||||
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
|
||||
"before_url": "https://cdn.example.com/templates/refined_12_before.jpg",
|
||||
"after_url": "https://cdn.example.com/templates/refined_12_after.jpg",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -135,6 +137,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"id": "tpl_atmosphere_06",
|
||||
"name": "暖阳",
|
||||
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
|
||||
"before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg",
|
||||
"after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -153,6 +157,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"id": "tpl_cover_03",
|
||||
"name": "旅行画册",
|
||||
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
|
||||
"before_url": "https://cdn.example.com/templates/cover_03_before.jpg",
|
||||
"after_url": "https://cdn.example.com/templates/cover_03_after.jpg",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -178,6 +184,7 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
|
||||
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
|
||||
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
|
||||
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
|
||||
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
|
||||
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
|
||||
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
|
||||
|
||||
@@ -403,23 +403,45 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
|
||||
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
|
||||
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let previewURL: String
|
||||
let beforeURL: String
|
||||
let afterURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case previewURL = "preview_url"
|
||||
case beforeURL = "before_url"
|
||||
case afterURL = "after_url"
|
||||
}
|
||||
|
||||
/// 创建 AI 修图模板。
|
||||
init(id: Int, name: String, previewURL: String) {
|
||||
init(
|
||||
id: Int,
|
||||
name: String,
|
||||
previewURL: String,
|
||||
beforeURL: String = "",
|
||||
afterURL: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.previewURL = previewURL
|
||||
self.beforeURL = beforeURL
|
||||
self.afterURL = afterURL
|
||||
}
|
||||
|
||||
/// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(Int.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
previewURL = try container.decode(String.self, forKey: .previewURL)
|
||||
beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? ""
|
||||
afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
|
||||
struct BeforeAfterComparisonContent: Sendable, Equatable {
|
||||
let beforeURL: String
|
||||
let afterURL: String
|
||||
let beforeLabel: String
|
||||
let afterLabel: String
|
||||
|
||||
/// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
|
||||
init(
|
||||
beforeURL: String,
|
||||
afterURL: String,
|
||||
beforeLabel: String = "原图",
|
||||
afterLabel: String = "效果图"
|
||||
) {
|
||||
self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.beforeLabel = beforeLabel
|
||||
self.afterLabel = afterLabel
|
||||
}
|
||||
|
||||
/// 两张图片地址均存在时才允许进入对比页。
|
||||
var isValid: Bool {
|
||||
!beforeURL.isEmpty && !afterURL.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
||||
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
||||
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
||||
@@ -85,6 +111,19 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
assets.first { $0.kind == kind }
|
||||
}
|
||||
|
||||
/// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
|
||||
func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
|
||||
guard kind == .retouched || kind == .atmosphere,
|
||||
let original = asset(for: .original),
|
||||
let result = asset(for: kind)
|
||||
else { return nil }
|
||||
let content = BeforeAfterComparisonContent(
|
||||
beforeURL: original.displayURL,
|
||||
afterURL: result.displayURL
|
||||
)
|
||||
return content.isValid ? content : nil
|
||||
}
|
||||
|
||||
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||
init(material: TravelAlbumMaterial) {
|
||||
originalMaterialId = material.id
|
||||
@@ -157,6 +196,14 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIRetouchTemplate {
|
||||
/// 使用接口返回的模板示例前后图构建对比页内容。
|
||||
var comparisonContent: BeforeAfterComparisonContent? {
|
||||
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
|
||||
return content.isValid ? content : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览分页节点,记录当前图片所属项目及类型。
|
||||
struct TravelAlbumPreviewNode: Sendable, Hashable {
|
||||
let projectIndex: Int
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// BeforeAfterComparisonViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 前后对比分隔线的共享运动参数与计算规则。
|
||||
enum BeforeAfterComparisonMotion {
|
||||
static let automaticCenter: CGFloat = 0.5
|
||||
static let automaticAmplitude: CGFloat = 0.3
|
||||
static let automaticSpeed: Double = 0.8
|
||||
static let manualRange: ClosedRange<CGFloat> = 0.05 ... 0.95
|
||||
|
||||
/// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
|
||||
static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
|
||||
automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
|
||||
}
|
||||
|
||||
/// 将手动拖动位置限制在两侧 5% 的安全边界内。
|
||||
static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
|
||||
min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
|
||||
}
|
||||
|
||||
/// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
|
||||
static func phaseTime(for fraction: CGFloat) -> TimeInterval {
|
||||
let normalized = Double(
|
||||
min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
|
||||
)
|
||||
return asin(normalized) / automaticSpeed
|
||||
}
|
||||
}
|
||||
|
||||
/// 前后对比图片在可用区域内完整展示时的等比布局计算。
|
||||
enum BeforeAfterComparisonLayout {
|
||||
static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
|
||||
|
||||
/// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
|
||||
static func aspectRatio(for imageSize: CGSize) -> CGFloat {
|
||||
guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
|
||||
let ratio = imageSize.width / imageSize.height
|
||||
return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
|
||||
}
|
||||
|
||||
/// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
|
||||
static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
|
||||
guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
|
||||
let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
|
||||
if maximumSize.width / maximumSize.height > ratio {
|
||||
return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
|
||||
}
|
||||
return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
|
||||
}
|
||||
}
|
||||
|
||||
/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
|
||||
final class BeforeAfterComparisonViewModel {
|
||||
let content: BeforeAfterComparisonContent
|
||||
|
||||
/// 创建指定内容的前后对比页状态。
|
||||
init(content: BeforeAfterComparisonContent) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// 原图远程地址。
|
||||
var beforeImageURL: URL? {
|
||||
URL(string: content.beforeURL)
|
||||
}
|
||||
|
||||
/// 效果图远程地址。
|
||||
var afterImageURL: URL? {
|
||||
URL(string: content.afterURL)
|
||||
}
|
||||
|
||||
/// 两张图片地址都可供页面加载时返回 true。
|
||||
var isValid: Bool {
|
||||
content.isValid && beforeImageURL != nil && afterImageURL != nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user