From 2a21f7131d6d15d012df8cb72878fb1b0001d650 Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Mon, 27 Jul 2026 15:40:44 +0000 Subject: [PATCH] Add SimilarityGroup model --- Sources/Photochk/Models/SimilarityGroup.swift | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 Sources/Photochk/Models/SimilarityGroup.swift diff --git a/Sources/Photochk/Models/SimilarityGroup.swift b/Sources/Photochk/Models/SimilarityGroup.swift new file mode 100644 index 0000000..c1732da --- /dev/null +++ b/Sources/Photochk/Models/SimilarityGroup.swift @@ -0,0 +1,75 @@ +import Foundation + +/// 似ていると判定された写真のまとまり。必ず2枚以上を含む。 +struct SimilarityGroup: Identifiable, Hashable, Sendable { + /// 代表写真とメンバーの関係。 + enum Relation: Hashable, Sendable { + /// バイト単位で完全に同一。 + case exact + /// 見た目が似ている。距離は Vision の feature print 由来で 0 に近いほど似ている。 + /// 画像をデコードできず距離を出せなかった場合は nil。 + case similar(distance: Double?) + + var isExact: Bool { + if case .exact = self { return true } + return false + } + + /// 並べ替え用。完全同一を最優先、距離不明は最後に回す。 + var sortKey: Double { + switch self { + case .exact: return -1 + case .similar(let d): return d ?? .greatestFiniteMagnitude + } + } + + var distanceText: String { + switch self { + case .exact: return "完全同一" + case .similar(let d?): return String(format: "距離 %.3f", d) + case .similar(nil): return "類似" + } + } + } + + struct Member: Identifiable, Hashable, Sendable { + var id: URL { asset.id } + let asset: PhotoAsset + let relation: Relation + + /// 代表以外にもバイト単位で同じファイルがある場合の目印("A", "B" …)。 + /// 単独なら nil。同じ印どうしは中身が完全に同じなので、どれを消しても失われるものはない。 + var exactSetTag: String? + } + + /// 1回のスキャン結果の中で代表は一意なので、そのまま識別子に使う。 + var id: URL { representativeID } + + /// 代表を先頭とし、以降は代表に近い順。 + let members: [Member] + let representativeID: URL + + var representative: PhotoAsset { + members.first(where: { $0.id == representativeID })?.asset ?? members[0].asset + } + + /// 全メンバーが代表と完全同一なら true。混在していれば false。 + var isAllExact: Bool { + members.allSatisfy { $0.id == representativeID || $0.relation.isExact } + } + + var kindText: String { isAllExact ? "完全同一" : "類似" } + + var totalBytes: Int64 { members.reduce(0) { $0 + $1.asset.fileSize } } + + /// 代表1枚を残した場合に削減できるバイト数。 + var reclaimableBytes: Int64 { totalBytes - representative.fileSize } + + var totalBytesText: String { + ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file) + } + + var reclaimableBytesText: String { + ByteCountFormatter.string(fromByteCount: reclaimableBytes, countStyle: .file) + } +}