Photochk 1.0.0 — 類似写真を探して整理する macOS アプリ
フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して グループにまとめ、残す1枚を選んでほかをゴミ箱へ送れるようにする。 検出は安い判定から順に4段: - ファイルサイズが同じものだけ SHA-256 でバイト一致を確定 - dHash(知覚ハッシュ)のハミング距離で候補ペアを抽出 - 候補に出てくる画像だけ Vision の feature print で精査 - Union-Find で連結成分をグループ化 判定のしきい値は実写画像で距離を実測して決めた(docs/calibration.md)。 tools/release-update.sh で Gitea のリリースを更新する。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 各グループで「どの1枚を残すか」の基準。
|
||||
enum KeepRule: String, CaseIterable, Identifiable {
|
||||
case highestResolution
|
||||
case newest
|
||||
case oldest
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .highestResolution: return "解像度が最も高い1枚を残す"
|
||||
case .newest: return "最も新しい1枚を残す"
|
||||
case .oldest: return "最も古い1枚を残す"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ScanViewModel {
|
||||
private(set) var folder: URL?
|
||||
private(set) var groups: [SimilarityGroup] = []
|
||||
private(set) var progress: ScanProgress?
|
||||
private(set) var hasScanned = false
|
||||
|
||||
var settings = ScanSettings() {
|
||||
didSet { if oldValue != settings, folder != nil { rescan() } }
|
||||
}
|
||||
|
||||
var selectedGroupID: SimilarityGroup.ID?
|
||||
var markedForDeletion: Set<URL> = []
|
||||
var errorMessage: String?
|
||||
|
||||
private var scanTask: Task<Void, Never>?
|
||||
private let cache = FingerprintCache()
|
||||
private let folderAccess = FolderAccess()
|
||||
|
||||
var isScanning: Bool { progress != nil }
|
||||
|
||||
var selectedGroup: SimilarityGroup? {
|
||||
guard let selectedGroupID else { return nil }
|
||||
return groups.first { $0.id == selectedGroupID }
|
||||
}
|
||||
|
||||
// MARK: - フォルダ
|
||||
|
||||
func restorePreviousFolder() {
|
||||
guard folder == nil, let url = folderAccess.restorePrevious() else { return }
|
||||
folder = url
|
||||
rescan()
|
||||
}
|
||||
|
||||
func chooseFolder() {
|
||||
guard let url = folderAccess.choose(startingAt: folder) else { return }
|
||||
folder = url
|
||||
rescan()
|
||||
}
|
||||
|
||||
// MARK: - スキャン
|
||||
|
||||
func rescan() {
|
||||
guard let folder else { return }
|
||||
|
||||
scanTask?.cancel()
|
||||
groups = []
|
||||
selectedGroupID = nil
|
||||
markedForDeletion = []
|
||||
errorMessage = nil
|
||||
progress = ScanProgress(phase: .scanning)
|
||||
|
||||
let settings = settings
|
||||
let cache = cache
|
||||
scanTask = Task { [weak self] in
|
||||
do {
|
||||
let result = try await SimilarityGrouper.run(
|
||||
root: folder,
|
||||
settings: settings,
|
||||
cache: cache,
|
||||
progress: { [weak self] update in
|
||||
Task { @MainActor in
|
||||
// キャンセル後に遅れて届いた更新は捨てる
|
||||
guard let self, self.isScanning else { return }
|
||||
self.progress = update
|
||||
}
|
||||
}
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
self?.finishScan(with: result)
|
||||
} catch is CancellationError {
|
||||
self?.progress = nil
|
||||
} catch {
|
||||
self?.errorMessage = error.localizedDescription
|
||||
self?.progress = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancelScan() {
|
||||
scanTask?.cancel()
|
||||
scanTask = nil
|
||||
progress = nil
|
||||
}
|
||||
|
||||
private func finishScan(with result: [SimilarityGroup]) {
|
||||
groups = result
|
||||
selectedGroupID = result.first?.id
|
||||
progress = nil
|
||||
hasScanned = true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// テスト専用。実際にスキャンせずに結果を差し込む。
|
||||
func setGroupsForTesting(_ groups: [SimilarityGroup]) {
|
||||
self.groups = groups
|
||||
self.selectedGroupID = groups.first?.id
|
||||
self.hasScanned = true
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 選択
|
||||
|
||||
/// そのグループで最後の1枚を消すことになる場合は選べない。
|
||||
func canMark(_ url: URL, in group: SimilarityGroup) -> Bool {
|
||||
if markedForDeletion.contains(url) { return true }
|
||||
let remaining = group.members.filter { !markedForDeletion.contains($0.id) }
|
||||
return remaining.count > 1
|
||||
}
|
||||
|
||||
func toggleMark(_ url: URL, in group: SimilarityGroup) {
|
||||
if markedForDeletion.contains(url) {
|
||||
markedForDeletion.remove(url)
|
||||
} else if canMark(url, in: group) {
|
||||
markedForDeletion.insert(url)
|
||||
}
|
||||
}
|
||||
|
||||
func applyKeepRule(_ rule: KeepRule) {
|
||||
var marks: Set<URL> = []
|
||||
for group in groups {
|
||||
guard let keeper = keeper(for: rule, in: group) else { continue }
|
||||
for member in group.members where member.id != keeper {
|
||||
marks.insert(member.id)
|
||||
}
|
||||
}
|
||||
markedForDeletion = marks
|
||||
}
|
||||
|
||||
func clearMarks() {
|
||||
markedForDeletion = []
|
||||
}
|
||||
|
||||
private func keeper(for rule: KeepRule, in group: SimilarityGroup) -> URL? {
|
||||
switch rule {
|
||||
case .highestResolution:
|
||||
return group.members.max {
|
||||
($0.asset.pixelCount, $0.asset.fileSize) < ($1.asset.pixelCount, $1.asset.fileSize)
|
||||
}?.id
|
||||
case .newest:
|
||||
return group.members.max {
|
||||
sortDate($0.asset) < sortDate($1.asset)
|
||||
}?.id
|
||||
case .oldest:
|
||||
return group.members.min {
|
||||
sortDate($0.asset) < sortDate($1.asset)
|
||||
}?.id
|
||||
}
|
||||
}
|
||||
|
||||
private func sortDate(_ asset: PhotoAsset) -> Date {
|
||||
asset.captureDate ?? asset.modificationDate
|
||||
}
|
||||
|
||||
// MARK: - 集計
|
||||
|
||||
var markedCount: Int { markedForDeletion.count }
|
||||
|
||||
var markedBytes: Int64 {
|
||||
groups
|
||||
.flatMap(\.members)
|
||||
.filter { markedForDeletion.contains($0.id) }
|
||||
.reduce(0) { $0 + $1.asset.fileSize }
|
||||
}
|
||||
|
||||
var markedBytesText: String {
|
||||
ByteCountFormatter.string(fromByteCount: markedBytes, countStyle: .file)
|
||||
}
|
||||
|
||||
var totalReclaimableText: String {
|
||||
let bytes = groups.reduce(0) { $0 + $1.reclaimableBytes }
|
||||
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
|
||||
}
|
||||
|
||||
var totalDuplicateCount: Int {
|
||||
groups.reduce(0) { $0 + $1.members.count - 1 }
|
||||
}
|
||||
|
||||
// MARK: - ゴミ箱へ
|
||||
|
||||
func moveMarkedToTrash() async {
|
||||
let targets = Array(markedForDeletion)
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
let outcome = await TrashService.moveToTrash(targets)
|
||||
let trashed = Set(outcome.trashed)
|
||||
guard !trashed.isEmpty || !outcome.failed.isEmpty else { return }
|
||||
|
||||
markedForDeletion.subtract(trashed)
|
||||
removeFromGroups(trashed)
|
||||
|
||||
if !outcome.failed.isEmpty {
|
||||
let names = outcome.failed.prefix(5).map(\.lastPathComponent).joined(separator: "\n")
|
||||
let more = outcome.failed.count > 5 ? "\nほか \(outcome.failed.count - 5) 件" : ""
|
||||
errorMessage = """
|
||||
\(outcome.failed.count) 件をゴミ箱へ移動できませんでした。
|
||||
\(names)\(more)
|
||||
\(outcome.errorDescription ?? "")
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
/// ゴミ箱に入れたファイルを一覧から取り除き、1枚だけになったグループは畳む。
|
||||
private func removeFromGroups(_ removed: Set<URL>) {
|
||||
var rebuilt: [SimilarityGroup] = []
|
||||
for group in groups {
|
||||
let survivors = group.members.filter { !removed.contains($0.id) }
|
||||
guard survivors.count > 1 else { continue }
|
||||
|
||||
let representativeID = survivors.contains { $0.id == group.representativeID }
|
||||
? group.representativeID
|
||||
: survivors[0].id
|
||||
rebuilt.append(
|
||||
SimilarityGroup(members: survivors, representativeID: representativeID)
|
||||
)
|
||||
}
|
||||
groups = rebuilt
|
||||
if let selectedGroupID, !rebuilt.contains(where: { $0.id == selectedGroupID }) {
|
||||
self.selectedGroupID = rebuilt.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user