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:
yfoxjp
2026-07-28 08:02:14 +09:00
co-authored by Claude Opus 5
commit c7f68b6cca
38 changed files with 3102 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import Foundation
/// 同時実行数を抑えつつ並列に変換し、入力と同じ順序で結果を返す。
///
/// 画像デコードや Vision の推論は CPU を使い切るので、無制限に投げるより
/// コア数程度に抑えたほうが速く、メモリも安定する。
func mapConcurrently<Item: Sendable, Output: Sendable>(
_ items: [Item],
limit: Int = max(2, ProcessInfo.processInfo.activeProcessorCount),
onProgress: (@Sendable (Int) -> Void)? = nil,
_ transform: @escaping @Sendable (Item) async -> Output
) async throws -> [Output] {
guard !items.isEmpty else { return [] }
var results = [Output?](repeating: nil, count: items.count)
try await withThrowingTaskGroup(of: (Int, Output).self) { group in
var next = 0
let initial = min(limit, items.count)
while next < initial {
let index = next
group.addTask { (index, await transform(items[index])) }
next += 1
}
var finished = 0
while let (index, value) = try await group.next() {
results[index] = value
finished += 1
onProgress?(finished)
try Task.checkCancellation()
if next < items.count {
let index = next
group.addTask { (index, await transform(items[index])) }
next += 1
}
}
}
return results.compactMap { $0 }
}
+18
View File
@@ -0,0 +1,18 @@
import CryptoKit
import Foundation
enum ContentHasher {
private static let chunkSize = 1 << 20 // 1 MB
/// ファイル全体を読み込まずに SHA-256 を求める。
static func sha256(of url: URL) throws -> String {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty {
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
}
+127
View File
@@ -0,0 +1,127 @@
import CoreGraphics
import Foundation
import ImageIO
/// 画像を1回デコードして得られる基本情報。dHash と表示用メタデータをまとめて取る。
struct ImageBasics: Codable, Hashable, Sendable {
let dHash: UInt64
let pixelWidth: Int
let pixelHeight: Int
let captureDate: Date?
}
/// 隣接ピクセルの明暗差から 64bit の知覚ハッシュ(dHash)を作る。
///
/// リサイズや再圧縮では値がほとんど変わらないので、feature print にかける候補ペアを
/// 安く絞り込むための一次フィルタとして使う。
enum DHash {
/// 9×8 に落として横隣接を比較するので 8×8 = 64bit になる。
private static let hashWidth = 9
private static let hashHeight = 8
static func compute(for url: URL) -> ImageBasics? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
CGImageSourceGetCount(source) > 0
else { return nil }
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]
let (width, height) = pixelSize(from: properties)
let captureDate = captureDate(from: properties)
// フルデコードを避けるため、まず小さなサムネイルを取り出す。
// WithTransform で EXIF の回転を適用しておく。Vision の feature print も同じく EXIF を
// 反映するので、これで「表示したときの見た目」どうしを比較する形に揃う。
// 結果として、回転が焼き込まれた版と EXIF で回している版は同じ写真として検出できる一方、
// 実際に向きが違う写真どうしは別物として扱われる。
let thumbnailOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: 64,
]
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(
source, 0, thumbnailOptions as CFDictionary
) else { return nil }
guard let hash = hash(of: thumbnail) else { return nil }
return ImageBasics(
dHash: hash,
pixelWidth: width,
pixelHeight: height,
captureDate: captureDate
)
}
/// 2つのハッシュがどれだけ違うか(異なるビット数)。
static func hammingDistance(_ a: UInt64, _ b: UInt64) -> Int {
(a ^ b).nonzeroBitCount
}
// MARK: - 内部
private static func hash(of image: CGImage) -> UInt64? {
let bytesPerRow = 16 // hashWidth(9) 以上で 16 バイト境界に揃える
var buffer = [UInt8](repeating: 0, count: bytesPerRow * hashHeight)
let ok = buffer.withUnsafeMutableBytes { raw -> Bool in
guard let context = CGContext(
data: raw.baseAddress,
width: hashWidth,
height: hashHeight,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return false }
context.interpolationQuality = .medium
context.draw(
image,
in: CGRect(x: 0, y: 0, width: hashWidth, height: hashHeight)
)
return true
}
guard ok else { return nil }
var hash: UInt64 = 0
var bit = 0
for y in 0..<hashHeight {
let row = y * bytesPerRow
for x in 0..<(hashWidth - 1) {
if buffer[row + x] > buffer[row + x + 1] {
hash |= (1 << UInt64(bit))
}
bit += 1
}
}
return hash
}
private static func pixelSize(from properties: [CFString: Any]?) -> (Int, Int) {
let width = properties?[kCGImagePropertyPixelWidth] as? Int ?? 0
let height = properties?[kCGImagePropertyPixelHeight] as? Int ?? 0
// EXIF の回転を反映した「見た目の」サイズを返す。
let orientation = properties?[kCGImagePropertyOrientation] as? Int ?? 1
return (5...8).contains(orientation) ? (height, width) : (width, height)
}
private static func captureDate(from properties: [CFString: Any]?) -> Date? {
guard let exif = properties?[kCGImagePropertyExifDictionary] as? [CFString: Any] else {
return nil
}
let raw = exif[kCGImagePropertyExifDateTimeOriginal] as? String
?? exif[kCGImagePropertyExifDateTimeDigitized] as? String
guard let raw else { return nil }
return exifDateFormatter.date(from: raw)
}
/// EXIF の日時は "2024:05:01 12:34:56" 形式。
private static let exifDateFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy:MM:dd HH:mm:ss"
return f
}()
}
@@ -0,0 +1,23 @@
import Foundation
import Vision
/// Vision の feature print で「見た目の近さ」を測る。
///
/// `perform(on: URL)` は Vision 側が ImageIO でデコードするため、HEIC や RAW もそのまま渡せる。
enum FeaturePrinter {
static func compute(for url: URL) async throws -> FeaturePrintObservation {
var request = GenerateImageFeaturePrintRequest()
// 縦横比が違う版(トリミング・リサイズ)でも比較できるよう、切り取らずに収める。
request.cropAndScaleAction = .scaleToFit
return try await request.perform(on: url)
}
/// 距離は 0 が同一。比較できない組み合わせでは nil を返す。
static func distance(
_ a: FeaturePrintObservation?,
_ b: FeaturePrintObservation?
) -> Double? {
guard let a, let b else { return nil }
return try? a.distance(to: b)
}
}
@@ -0,0 +1,118 @@
import Foundation
import Vision
/// 1ファイル分のキャッシュ。ファイルサイズと更新日時が一致するときだけ再利用する。
struct FingerprintRecord: Codable, Sendable {
var fileSize: Int64
var modificationDate: Date
var basics: ImageBasics?
var featurePrint: FeaturePrintObservation?
func matches(_ file: ScannedFile) -> Bool {
fileSize == file.fileSize
&& abs(modificationDate.timeIntervalSince(file.modificationDate)) < 1
}
}
/// dHash と feature print をディスクに保存し、同じフォルダの再スキャンを速くする。
///
/// feature print は1枚あたり数 KB あるので、JSON ではなくバイナリ plist で持つ。
actor FingerprintCache {
private var records: [String: FingerprintRecord] = [:]
private var isDirty = false
private let fileURL: URL?
init(fileURL: URL? = FingerprintCache.defaultFileURL()) {
self.fileURL = fileURL
self.records = Self.loadRecords(from: fileURL)
}
static func defaultFileURL() -> URL? {
guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
else { return nil }
let directory = caches.appendingPathComponent("Photochk", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent("fingerprints.plist")
}
// MARK: - 読み出し
func basics(for files: [ScannedFile]) -> [URL: ImageBasics] {
var found: [URL: ImageBasics] = [:]
for file in files {
if let record = records[file.url.path], record.matches(file), let basics = record.basics {
found[file.url] = basics
}
}
return found
}
func featurePrints(for files: [ScannedFile]) -> [URL: FeaturePrintObservation] {
var found: [URL: FeaturePrintObservation] = [:]
for file in files {
if let record = records[file.url.path], record.matches(file),
let print = record.featurePrint {
found[file.url] = print
}
}
return found
}
// MARK: - 書き込み
func store(basics: [URL: ImageBasics], for files: [ScannedFile]) {
for file in files {
guard let value = basics[file.url] else { continue }
update(file) { $0.basics = value }
}
}
func store(featurePrints: [URL: FeaturePrintObservation], for files: [ScannedFile]) {
for file in files {
guard let value = featurePrints[file.url] else { continue }
update(file) { $0.featurePrint = value }
}
}
private func update(_ file: ScannedFile, _ body: (inout FingerprintRecord) -> Void) {
var record = records[file.url.path] ?? FingerprintRecord(
fileSize: file.fileSize,
modificationDate: file.modificationDate
)
if !record.matches(file) {
// ファイルが変わっていたら作り直す
record = FingerprintRecord(
fileSize: file.fileSize,
modificationDate: file.modificationDate
)
}
body(&record)
records[file.url.path] = record
isDirty = true
}
/// 消えたファイルのエントリを捨てる。スキャンのたびに肥大化するのを防ぐ。
func prune(keeping files: [ScannedFile]) {
let alive = Set(files.map(\.url.path))
let before = records.count
records = records.filter { alive.contains($0.key) }
if records.count != before { isDirty = true }
}
// MARK: - 永続化
func save() {
guard isDirty, let fileURL else { return }
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
guard let data = try? encoder.encode(records) else { return }
try? data.write(to: fileURL, options: .atomic)
isDirty = false
}
private static func loadRecords(from fileURL: URL?) -> [String: FingerprintRecord] {
guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return [:] }
// 形式が変わっていたら黙って捨てる。キャッシュなので作り直せばよい。
return (try? PropertyListDecoder().decode([String: FingerprintRecord].self, from: data)) ?? [:]
}
}
+74
View File
@@ -0,0 +1,74 @@
import Foundation
import UniformTypeIdentifiers
/// 走査で見つかった画像ファイル1件。中身はまだ読んでいない。
struct ScannedFile: Hashable, Sendable {
let url: URL
let fileSize: Int64
let modificationDate: Date
let creationDate: Date?
}
enum PhotoScanner {
/// フォルダ配下の画像ファイルを列挙する。
///
/// UTType が `.image` に conform するものだけを拾うので、JPEG / PNG / HEIC / TIFF に加えて
/// 各社の RAW も自動的に対象になる。写真ライブラリなどのパッケージは中に入らない。
static func scan(root: URL, includesSubfolders: Bool) throws -> [ScannedFile] {
let keys: [URLResourceKey] = [
.isRegularFileKey,
.contentTypeKey,
.fileSizeKey,
.contentModificationDateKey,
.creationDateKey,
]
var options: FileManager.DirectoryEnumerationOptions = [
.skipsHiddenFiles,
.skipsPackageDescendants,
]
if !includesSubfolders {
options.insert(.skipsSubdirectoryDescendants)
}
guard let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: keys,
options: options
) else {
throw ScanError.cannotEnumerate(root)
}
var results: [ScannedFile] = []
for case let url as URL in enumerator {
try Task.checkCancellation()
guard let values = try? url.resourceValues(forKeys: Set(keys)),
values.isRegularFile == true,
let type = values.contentType,
type.conforms(to: .image),
let size = values.fileSize, size > 0
else { continue }
results.append(
ScannedFile(
url: url,
fileSize: Int64(size),
modificationDate: values.contentModificationDate ?? .distantPast,
creationDate: values.creationDate
)
)
}
return results
}
}
enum ScanError: LocalizedError {
case cannotEnumerate(URL)
var errorDescription: String? {
switch self {
case .cannotEnumerate(let url):
return "「\(url.lastPathComponent)」の中身を読み取れませんでした。"
}
}
}
@@ -0,0 +1,351 @@
import Foundation
import Vision
/// 走査からグループ生成までの一連の処理をまとめる。
///
/// 安いフィルタから順にかけて候補を絞る:
/// 1. ファイルサイズが同じものだけ SHA-256 → 完全同一を確定
/// 2. dHash(知覚ハッシュ)で「見た目が近そうな」ペアを抽出
/// 3. そのペアに登場する画像だけ Vision の feature print で精査
/// 4. Union-Find で連結成分をグループ化
enum SimilarityGrouper {
private struct Pair: Sendable {
let a: Int
let b: Int
}
private struct HashEntry: Sendable {
let index: Int
let hash: UInt64
}
/// feature print を計算できなかったときの保険。dHash がここまで一致していれば同一とみなす。
private static let fallbackHammingThreshold = 4
static func run(
root: URL,
settings: ScanSettings,
cache: FingerprintCache,
progress: @escaping @Sendable (ScanProgress) -> Void
) async throws -> [SimilarityGroup] {
progress(ScanProgress(phase: .scanning))
let files = try PhotoScanner.scan(root: root, includesSubfolders: settings.includesSubfolders)
try Task.checkCancellation()
guard files.count > 1 else { return [] }
// --- Stage 1: 完全同一をまとめ、以降は各セットの代表1枚だけを扱う ---
let leaderOf = try await exactLeaders(files: files, progress: progress)
let leaderIndices = files.indices.filter { leaderOf[$0] == $0 }
let leaderFiles = leaderIndices.map { files[$0] }
// --- Stage 2: dHash ---
let basicsByURL = try await loadBasics(for: leaderFiles, cache: cache, progress: progress)
// --- Stage 3: 候補ペア → feature print ---
let entries = leaderIndices.compactMap { index -> HashEntry? in
guard let basics = basicsByURL[files[index].url] else { return nil }
return HashEntry(index: index, hash: basics.dHash)
}
progress(ScanProgress(phase: .comparing))
let pairs = try await candidatePairs(
entries,
threshold: settings.sensitivity.hammingThreshold
)
var involved = Set<Int>()
for pair in pairs {
involved.insert(pair.a)
involved.insert(pair.b)
}
let involvedFiles = involved.sorted().map { files[$0] }
let printsByURL = try await loadFeaturePrints(
for: involvedFiles, cache: cache, progress: progress
)
// --- Stage 4: グループ化 ---
progress(ScanProgress(phase: .grouping))
var unionFind = UnionFind(count: files.count)
for index in files.indices where leaderOf[index] != index {
unionFind.union(index, leaderOf[index])
}
let threshold = settings.sensitivity.featurePrintThreshold
for pair in pairs {
let printA = printsByURL[files[pair.a].url]
let printB = printsByURL[files[pair.b].url]
if let distance = FeaturePrinter.distance(printA, printB) {
if distance < threshold { unionFind.union(pair.a, pair.b) }
} else if let basicsA = basicsByURL[files[pair.a].url],
let basicsB = basicsByURL[files[pair.b].url],
DHash.hammingDistance(basicsA.dHash, basicsB.dHash) <= fallbackHammingThreshold {
unionFind.union(pair.a, pair.b)
}
}
try Task.checkCancellation()
let groups = buildGroups(
components: unionFind.components(),
files: files,
leaderOf: leaderOf,
basicsByURL: basicsByURL,
printsByURL: printsByURL
)
await cache.prune(keeping: files)
await cache.save()
return groups
}
// MARK: - Stage 1
/// 各インデックスに「完全同一セットの代表インデックス」を割り当てる。単独なら自分自身。
private static func exactLeaders(
files: [ScannedFile],
progress: @escaping @Sendable (ScanProgress) -> Void
) async throws -> [Int] {
var leaderOf = Array(files.indices)
// サイズが違えば中身も違う。同サイズのものだけハッシュすればよい。
var bySize: [Int64: [Int]] = [:]
for (index, file) in files.enumerated() {
bySize[file.fileSize, default: []].append(index)
}
let candidates = bySize.values.filter { $0.count > 1 }.flatMap { $0 }.sorted()
guard !candidates.isEmpty else { return leaderOf }
progress(ScanProgress(phase: .hashing, completed: 0, total: candidates.count))
let hashes = try await mapConcurrently(
candidates,
onProgress: { done in
progress(ScanProgress(phase: .hashing, completed: done, total: candidates.count))
}
) { index -> String? in
try? ContentHasher.sha256(of: files[index].url)
}
var byDigest: [String: [Int]] = [:]
for (index, hash) in zip(candidates, hashes) {
guard let hash else { continue }
byDigest["\(files[index].fileSize):\(hash)", default: []].append(index)
}
for indices in byDigest.values where indices.count > 1 {
guard let leader = indices.min(by: { files[$0].url.path < files[$1].url.path }) else {
continue
}
for index in indices { leaderOf[index] = leader }
}
return leaderOf
}
// MARK: - Stage 2 / 3
private static func loadBasics(
for files: [ScannedFile],
cache: FingerprintCache,
progress: @escaping @Sendable (ScanProgress) -> Void
) async throws -> [URL: ImageBasics] {
var result = await cache.basics(for: files)
let missing = files.filter { result[$0.url] == nil }
guard !missing.isEmpty else { return result }
let cached = files.count - missing.count
progress(ScanProgress(phase: .fingerprinting, completed: cached, total: files.count))
let computed = try await mapConcurrently(
missing,
onProgress: { done in
progress(
ScanProgress(phase: .fingerprinting, completed: cached + done, total: files.count)
)
}
) { file -> ImageBasics? in
DHash.compute(for: file.url)
}
var fresh: [URL: ImageBasics] = [:]
for (file, basics) in zip(missing, computed) {
guard let basics else { continue }
fresh[file.url] = basics
result[file.url] = basics
}
await cache.store(basics: fresh, for: missing)
return result
}
private static func loadFeaturePrints(
for files: [ScannedFile],
cache: FingerprintCache,
progress: @escaping @Sendable (ScanProgress) -> Void
) async throws -> [URL: FeaturePrintObservation] {
guard !files.isEmpty else { return [:] }
var result = await cache.featurePrints(for: files)
let missing = files.filter { result[$0.url] == nil }
guard !missing.isEmpty else { return result }
let cached = files.count - missing.count
progress(ScanProgress(phase: .comparing, completed: cached, total: files.count))
let computed = try await mapConcurrently(
missing,
onProgress: { done in
progress(
ScanProgress(phase: .comparing, completed: cached + done, total: files.count)
)
}
) { file -> FeaturePrintObservation? in
try? await FeaturePrinter.compute(for: file.url)
}
var fresh: [URL: FeaturePrintObservation] = [:]
for (file, print) in zip(missing, computed) {
guard let print else { continue }
fresh[file.url] = print
result[file.url] = print
}
await cache.store(featurePrints: fresh, for: missing)
return result
}
/// dHash のハミング距離がしきい値以下のペアを総当たりで探す。
///
/// 64bit の XOR + popcount なので1ペアあたりの実費はごく小さい。LSH などで
/// 絞ると取りこぼしが出るため、区間をコアに分けて総当たりする方針にしている。
private static func candidatePairs(
_ entries: [HashEntry],
threshold: Int
) async throws -> [Pair] {
guard entries.count > 1 else { return [] }
let cores = max(2, ProcessInfo.processInfo.activeProcessorCount)
let chunkSize = max(64, entries.count / (cores * 4))
let starts = stride(from: 0, to: entries.count, by: chunkSize).map { $0 }
let chunks = try await mapConcurrently(starts) { start -> [Pair] in
var found: [Pair] = []
let end = min(start + chunkSize, entries.count)
for i in start..<end {
let hash = entries[i].hash
for j in (i + 1)..<entries.count
where DHash.hammingDistance(hash, entries[j].hash) <= threshold {
found.append(Pair(a: entries[i].index, b: entries[j].index))
}
}
return found
}
return chunks.flatMap { $0 }
}
// MARK: - Stage 4
/// グループの中に「代表とは別だが、互いにバイト単位で同じ」ファイルの組があれば印を付ける。
///
/// 代表と同一のものは相手が「完全同一」と表示されるので印は要らない。ここで拾うのは
/// たとえば縮小版が2つあってその2つだけが同一、といったケース。
private static func exactSetTags(
in ranked: [Int],
leaderOf: [Int],
excluding representativeLeader: Int
) -> [Int: String] {
var membersPerLeader: [Int: Int] = [:]
for index in ranked where leaderOf[index] != representativeLeader {
membersPerLeader[leaderOf[index], default: 0] += 1
}
let labels = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
var tags: [Int: String] = [:]
// ranked の順に印を振るので、同じ結果からは常に同じ印になる。
for index in ranked {
let leader = leaderOf[index]
guard membersPerLeader[leader, default: 0] > 1, tags[leader] == nil else { continue }
tags[leader] = tags.count < labels.count
? String(labels[tags.count])
: "\(tags.count + 1)"
}
return tags
}
private static func buildGroups(
components: [[Int]],
files: [ScannedFile],
leaderOf: [Int],
basicsByURL: [URL: ImageBasics],
printsByURL: [URL: FeaturePrintObservation]
) -> [SimilarityGroup] {
func makeAsset(_ index: Int) -> PhotoAsset {
let file = files[index]
// 完全同一なら中身は同じなので、代表の解像度・撮影日をそのまま使える。
let basics = basicsByURL[files[leaderOf[index]].url]
return PhotoAsset(
url: file.url,
fileSize: file.fileSize,
modificationDate: file.modificationDate,
captureDate: basics?.captureDate ?? file.creationDate,
pixelWidth: basics?.pixelWidth ?? 0,
pixelHeight: basics?.pixelHeight ?? 0
)
}
var groups: [SimilarityGroup] = []
for component in components where component.count > 1 {
let assets = Dictionary(uniqueKeysWithValues: component.map { ($0, makeAsset($0)) })
// 代表は「解像度が最大 → ファイルサイズが最大 → 撮影が最古」で選ぶ。
let ranked = component.sorted { lhs, rhs in
guard let a = assets[lhs], let b = assets[rhs] else { return lhs < rhs }
if a.pixelCount != b.pixelCount { return a.pixelCount > b.pixelCount }
if a.fileSize != b.fileSize { return a.fileSize > b.fileSize }
let dateA = a.captureDate ?? .distantFuture
let dateB = b.captureDate ?? .distantFuture
if dateA != dateB { return dateA < dateB }
return a.url.path < b.url.path
}
guard let representativeIndex = ranked.first,
let representative = assets[representativeIndex]
else { continue }
let representativeLeader = leaderOf[representativeIndex]
let exactSetTags = exactSetTags(
in: ranked, leaderOf: leaderOf, excluding: representativeLeader
)
let members = component.compactMap { index -> SimilarityGroup.Member? in
guard let asset = assets[index] else { return nil }
let relation: SimilarityGroup.Relation
if leaderOf[index] == representativeLeader {
relation = .exact
} else {
relation = .similar(
distance: FeaturePrinter.distance(
printsByURL[files[leaderOf[index]].url],
printsByURL[files[representativeLeader].url]
)
)
}
return SimilarityGroup.Member(
asset: asset,
relation: relation,
exactSetTag: exactSetTags[leaderOf[index]]
)
}
let ordered = members.sorted { lhs, rhs in
if lhs.id == representative.url { return true }
if rhs.id == representative.url { return false }
if lhs.relation.sortKey != rhs.relation.sortKey {
return lhs.relation.sortKey < rhs.relation.sortKey
}
return lhs.asset.url.path < rhs.asset.url.path
}
groups.append(
SimilarityGroup(members: ordered, representativeID: representative.url)
)
}
// 減らせる容量が大きいグループから見せる。
return groups.sorted { lhs, rhs in
if lhs.reclaimableBytes != rhs.reclaimableBytes {
return lhs.reclaimableBytes > rhs.reclaimableBytes
}
return lhs.representativeID.path < rhs.representativeID.path
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import Foundation
/// 経路圧縮 + ランク併合の Union-Find。類似ペアから連結成分(グループ)を作るのに使う。
struct UnionFind {
private var parent: [Int]
private var rank: [Int]
init(count: Int) {
parent = Array(0..<count)
rank = Array(repeating: 0, count: count)
}
mutating func find(_ x: Int) -> Int {
var root = x
while parent[root] != root { root = parent[root] }
// 経路圧縮
var node = x
while parent[node] != root {
let next = parent[node]
parent[node] = root
node = next
}
return root
}
@discardableResult
mutating func union(_ a: Int, _ b: Int) -> Bool {
let ra = find(a)
let rb = find(b)
guard ra != rb else { return false }
if rank[ra] < rank[rb] {
parent[ra] = rb
} else if rank[ra] > rank[rb] {
parent[rb] = ra
} else {
parent[rb] = ra
rank[ra] += 1
}
return true
}
/// 連結成分を、根のインデックスをキーにしてまとめて返す。
mutating func components() -> [[Int]] {
var buckets: [Int: [Int]] = [:]
for i in parent.indices {
buckets[find(i), default: []].append(i)
}
return Array(buckets.values)
}
}
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>ja</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string></string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
</dict>
</plist>
+40
View File
@@ -0,0 +1,40 @@
import Foundation
/// スキャン対象の写真1枚。ファイル URL を同一性とする。
struct PhotoAsset: Identifiable, Hashable, Sendable {
var id: URL { url }
let url: URL
let fileSize: Int64
let modificationDate: Date
/// EXIF の撮影日時。無ければファイルの作成日。
var captureDate: Date?
var pixelWidth: Int
var pixelHeight: Int
var fileName: String { url.lastPathComponent }
/// 画素数。代表選出の第一基準。
var pixelCount: Int { pixelWidth * pixelHeight }
var dimensionText: String {
pixelWidth > 0 && pixelHeight > 0 ? "\(pixelWidth) × \(pixelHeight)" : "—"
}
var fileSizeText: String {
ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file)
}
var captureDateText: String {
guard let captureDate else { return "—" }
return PhotoAsset.displayDateFormatter.string(from: captureDate)
}
private static let displayDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
}
@@ -0,0 +1,90 @@
import Foundation
/// 類似判定の厳しさ。UI の3段階と検出パラメータを結び付ける。
enum Sensitivity: String, CaseIterable, Identifiable, Sendable {
case strict
case standard
case loose
var id: String { rawValue }
var label: String {
switch self {
case .strict: return "厳しい"
case .standard: return "標準"
case .loose: return "ゆるい"
}
}
var help: String {
switch self {
case .strict: return "コピーやリサイズなど、ほぼ同じ写真だけを検出します"
case .standard: return "リサイズ・再圧縮された同じ写真と、連写の近いカットを検出します"
case .loose: return "トリミングされた版や、似た構図の別カットも検出します"
}
}
/// Vision の feature print 距離のしきい値。これ未満なら「似ている」。
///
/// 実写画像で計測した距離の目安(`docs/calibration.md` 参照):
/// 再エンコード 0.00 / 50%縮小 0.05 / 低画質 0.12 / 25%縮小 0.16 /
/// 連写 0.01 / 同一シーンの別カット 0.02〜0.04 / トリミング 0.42〜0.48 /
/// 無関係な写真 1.38 以上。
var featurePrintThreshold: Double {
switch self {
case .strict: return 0.20 // 25%まで縮小された版までを拾う
case .standard: return 0.30 // 縮小・再圧縮・連写まで。トリミングは拾わない
case .loose: return 0.55 // トリミング版まで拾う。無関係な写真とはなお十分離れている
}
}
/// dHash のハミング距離のしきい値。ここを通ったペアだけ feature print で精査する。
///
/// 同じ計測でのハミング距離: 縮小・低画質 1〜5 / トリミング 9〜17 / 無関係 22〜27。
var hammingThreshold: Int {
switch self {
case .strict: return 8
case .standard: return 12
case .loose: return 20
}
}
}
struct ScanSettings: Equatable, Sendable {
var includesSubfolders = true
var sensitivity: Sensitivity = .standard
}
/// スキャンの進捗。UI にそのまま表示する。
struct ScanProgress: Equatable, Sendable {
enum Phase: Equatable, Sendable {
case scanning
case hashing
case fingerprinting
case comparing
case grouping
var label: String {
switch self {
case .scanning: return "フォルダを走査中"
case .hashing: return "重複を判定中"
case .fingerprinting: return "画像の特徴を計算中"
case .comparing: return "類似度を比較中"
case .grouping: return "グループにまとめ中"
}
}
}
var phase: Phase
var completed: Int = 0
var total: Int = 0
var fractionCompleted: Double? {
guard total > 0 else { return nil }
return min(1, Double(completed) / Double(total))
}
var detailText: String {
total > 0 ? "\(completed) / \(total)" : ""
}
}
@@ -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)
}
}
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
</dict>
</plist>
+25
View File
@@ -0,0 +1,25 @@
import SwiftUI
@main
struct PhotochkApp: App {
@State private var model = ScanViewModel()
var body: some Scene {
WindowGroup {
ContentView(model: model)
.task { model.restorePreviousFolder() }
}
.defaultSize(width: 1120, height: 760)
.commands {
CommandGroup(replacing: .newItem) {
Button("フォルダを選ぶ…") { model.chooseFolder() }
.keyboardShortcut("o", modifiers: .command)
}
CommandGroup(after: .toolbar) {
Button("再スキャン") { model.rescan() }
.keyboardShortcut("r", modifiers: .command)
.disabled(model.folder == nil || model.isScanning)
}
}
}
}
@@ -0,0 +1,73 @@
import AppKit
import Foundation
/// フォルダの選択と、サンドボックス下でのアクセス権の保持を担う。
///
/// `NSOpenPanel` で選んだフォルダは配下ごとアクセスが許可される。security-scoped bookmark を
/// 保存しておくと、次回起動時に同じフォルダをそのまま開き直せる。
@MainActor
final class FolderAccess {
private static let bookmarkKey = "lastFolderBookmark"
private var accessedURL: URL?
/// フォルダ選択パネルを出す。キャンセルされたら nil。
func choose(startingAt current: URL?) -> URL? {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "このフォルダを調べる"
panel.message = "似た写真を探すフォルダを選んでください"
if let current { panel.directoryURL = current }
guard panel.runModal() == .OK, let url = panel.url else { return nil }
beginAccess(to: url)
saveBookmark(for: url)
return url
}
/// 前回のフォルダを復元する。無ければ nil。
func restorePrevious() -> URL? {
guard let data = UserDefaults.standard.data(forKey: Self.bookmarkKey) else { return nil }
var isStale = false
guard let url = try? URL(
resolvingBookmarkData: data,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
) else {
UserDefaults.standard.removeObject(forKey: Self.bookmarkKey)
return nil
}
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
beginAccess(to: url)
if isStale { saveBookmark(for: url) }
return url
}
func releaseAccess() {
accessedURL?.stopAccessingSecurityScopedResource()
accessedURL = nil
}
// MARK: - 内部
private func beginAccess(to url: URL) {
releaseAccess()
if url.startAccessingSecurityScopedResource() {
accessedURL = url
}
}
private func saveBookmark(for url: URL) {
guard let data = try? url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
) else { return }
UserDefaults.standard.set(data, forKey: Self.bookmarkKey)
}
}
@@ -0,0 +1,46 @@
import AppKit
import Foundation
import QuickLookThumbnailing
/// サムネイル生成。QuickLook 任せなので HEIC や RAW もそのまま扱える。
@MainActor
final class ThumbnailLoader {
static let shared = ThumbnailLoader()
private let cache = NSCache<NSString, NSImage>()
private init() {
cache.countLimit = 600
}
/// すでに読み込み済みなら即座に返す。スクロールで戻ってきたときに
/// 一瞬プレースホルダが出るのを防ぐために使う。
func cached(for url: URL, size: CGFloat) -> NSImage? {
cache.object(forKey: Self.key(url, size))
}
func thumbnail(for url: URL, size: CGFloat) async -> NSImage? {
let key = Self.key(url, size)
if let cached = cache.object(forKey: key) { return cached }
let scale = NSScreen.main?.backingScaleFactor ?? 2
let request = QLThumbnailGenerator.Request(
fileAt: url,
size: CGSize(width: size, height: size),
scale: scale,
representationTypes: .thumbnail
)
guard let representation = try? await QLThumbnailGenerator.shared
.generateBestRepresentation(for: request)
else { return nil }
let image = NSImage(cgImage: representation.cgImage, size: representation.contentRect.size)
cache.setObject(image, forKey: key)
return image
}
private static func key(_ url: URL, _ size: CGFloat) -> NSString {
"\(url.path)|\(Int(size))" as NSString
}
}
@@ -0,0 +1,30 @@
import AppKit
import Foundation
/// 選択されたファイルをゴミ箱へ送る。削除ではないので Finder から戻せる。
enum TrashService {
struct Outcome: Sendable {
var trashed: [URL] = []
var failed: [URL] = []
var errorDescription: String?
}
@MainActor
static func moveToTrash(_ urls: [URL]) async -> Outcome {
guard !urls.isEmpty else { return Outcome() }
let (moved, error): ([URL: URL], Error?) = await withCheckedContinuation { continuation in
NSWorkspace.shared.recycle(urls) { newURLs, error in
continuation.resume(returning: (newURLs, error))
}
}
let trashed = urls.filter { moved[$0] != nil }
let failed = urls.filter { moved[$0] == nil }
return Outcome(
trashed: trashed,
failed: failed,
errorDescription: failed.isEmpty ? nil : error?.localizedDescription
)
}
}
@@ -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
}
}
}
+145
View File
@@ -0,0 +1,145 @@
import SwiftUI
struct ContentView: View {
@Bindable var model: ScanViewModel
@State private var isConfirmingTrash = false
var body: some View {
NavigationSplitView {
GroupListView(model: model)
.navigationSplitViewColumnWidth(min: 270, ideal: 310, max: 420)
} detail: {
if let group = model.selectedGroup {
GroupDetailView(model: model, group: group)
} else {
placeholder
}
}
.toolbar { ScanToolbar(model: model, isConfirmingTrash: $isConfirmingTrash) }
.overlay {
if let progress = model.progress {
Color.black.opacity(0.12).ignoresSafeArea()
ScanProgressView(progress: progress) { model.cancelScan() }
}
}
.confirmationDialog(
"\(model.markedCount) 枚をゴミ箱へ移動します",
isPresented: $isConfirmingTrash,
titleVisibility: .visible
) {
Button("ゴミ箱へ移動(\(model.markedBytesText))", role: .destructive) {
Task { await model.moveMarkedToTrash() }
}
Button("キャンセル", role: .cancel) {}
} message: {
Text("削除ではなくゴミ箱への移動です。Finder のゴミ箱からいつでも元に戻せます。")
}
.alert(
"エラー",
isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)
) {
Button("OK") { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "")
}
}
@ViewBuilder
private var placeholder: some View {
if model.folder == nil {
ContentUnavailableView {
Label("フォルダを選んでください", systemImage: "photo.stack")
} description: {
Text("選んだフォルダの中から、内容が同じ写真とよく似た写真を探します。")
} actions: {
Button("フォルダを選ぶ…") { model.chooseFolder() }
.buttonStyle(.borderedProminent)
}
} else if model.hasScanned && model.groups.isEmpty {
ContentUnavailableView {
Label("似た写真は見つかりませんでした", systemImage: "checkmark.circle")
} description: {
Text("判定をゆるくすると、より広く似た写真を拾えます。")
}
} else {
ContentUnavailableView(
"グループを選択してください",
systemImage: "square.grid.2x2"
)
}
}
}
struct ScanToolbar: ToolbarContent {
@Bindable var model: ScanViewModel
@Binding var isConfirmingTrash: Bool
var body: some ToolbarContent {
ToolbarItem(placement: .navigation) {
Button {
model.chooseFolder()
} label: {
Label("フォルダを選ぶ", systemImage: "folder")
}
.help(model.folder?.path ?? "調べるフォルダを選びます")
}
ToolbarItem {
Picker("類似度", selection: $model.settings.sensitivity) {
ForEach(Sensitivity.allCases) { level in
Text(level.label).tag(level)
}
}
.pickerStyle(.segmented)
.frame(width: 220)
.help(model.settings.sensitivity.help)
.disabled(model.isScanning)
}
ToolbarItem {
Toggle(isOn: $model.settings.includesSubfolders) {
Label("サブフォルダも含む", systemImage: "folder.badge.plus")
}
.help("サブフォルダの中まで再帰的に調べます")
.disabled(model.isScanning)
}
ToolbarItem {
Menu {
ForEach(KeepRule.allCases) { rule in
Button(rule.label) { model.applyKeepRule(rule) }
}
Divider()
Button("選択をすべて解除") { model.clearMarks() }
} label: {
Label("一括選択", systemImage: "checklist")
}
.disabled(model.groups.isEmpty)
}
ToolbarItem {
Button {
isConfirmingTrash = true
} label: {
Label(
model.markedCount > 0 ? "\(model.markedCount) 枚をゴミ箱へ" : "ゴミ箱へ",
systemImage: "trash"
)
}
.disabled(model.markedCount == 0)
}
ToolbarItem {
Button {
model.rescan()
} label: {
Label("再スキャン", systemImage: "arrow.clockwise")
}
.disabled(model.folder == nil || model.isScanning)
}
}
}
@@ -0,0 +1,57 @@
import SwiftUI
struct GroupDetailView: View {
@Bindable var model: ScanViewModel
let group: SimilarityGroup
private let columns = [GridItem(.adaptive(minimum: 168, maximum: 168), spacing: 18, alignment: .top)]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, alignment: .leading, spacing: 20) {
ForEach(group.members) { member in
PhotoTileView(
member: member,
isRepresentative: member.id == group.representativeID,
isMarked: model.markedForDeletion.contains(member.id),
canMark: model.canMark(member.id, in: group),
onToggle: { model.toggleMark(member.id, in: group) }
)
}
}
.padding(20)
}
.safeAreaInset(edge: .top) { header }
.navigationTitle(group.representative.fileName)
.navigationSubtitle("\(group.members.count) 枚")
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(group.kindText)
.font(.caption.weight(.semibold))
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(
group.isAllExact ? Color.orange.opacity(0.22) : Color.accentColor.opacity(0.18),
in: Capsule()
)
Text("\(group.members.count) 枚・合計 \(group.totalBytesText)")
Text("1枚残すと \(group.reclaimableBytesText) 削減")
.foregroundStyle(.secondary)
}
.font(.callout)
Text(group.representative.url.deletingLastPathComponent().path)
.font(.caption)
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.head)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.bar)
}
}
+114
View File
@@ -0,0 +1,114 @@
import SwiftUI
struct GroupListView: View {
@Bindable var model: ScanViewModel
var body: some View {
List(selection: $model.selectedGroupID) {
if !model.groups.isEmpty {
Section {
ForEach(model.groups) { group in
GroupRow(group: group, markedCount: markedCount(in: group))
.tag(group.id)
}
} header: {
Text("\(model.groups.count) グループ・重複 \(model.totalDuplicateCount) 枚")
}
}
}
.listStyle(.sidebar)
.overlay {
if model.groups.isEmpty && !model.isScanning {
sidebarPlaceholder
}
}
.safeAreaInset(edge: .bottom) {
if !model.groups.isEmpty {
summaryBar
}
}
}
private func markedCount(in group: SimilarityGroup) -> Int {
group.members.filter { model.markedForDeletion.contains($0.id) }.count
}
private var sidebarPlaceholder: some View {
VStack(spacing: 6) {
if model.folder == nil {
Text("フォルダが未選択です")
} else if model.hasScanned {
Text("似た写真は見つかりませんでした")
} else {
Text("スキャンしていません")
}
}
.font(.callout)
.foregroundStyle(.secondary)
.padding()
}
private var summaryBar: some View {
VStack(alignment: .leading, spacing: 2) {
Divider()
Text("最大 \(model.totalReclaimableText) 削減できます")
.font(.callout)
if model.markedCount > 0 {
Text("\(model.markedCount) 枚を選択中(\(model.markedBytesText))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.bottom, 8)
.background(.bar)
}
}
struct GroupRow: View {
let group: SimilarityGroup
let markedCount: Int
var body: some View {
HStack(spacing: 10) {
ThumbnailView(url: group.representativeID, size: 52)
VStack(alignment: .leading, spacing: 3) {
Text(group.representative.fileName)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: 6) {
Text(group.kindText)
.font(.caption2)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(
group.isAllExact ? Color.orange.opacity(0.22) : Color.accentColor.opacity(0.18),
in: Capsule()
)
Text("\(group.members.count) 枚")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("\(group.reclaimableBytesText) 削減可")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if markedCount > 0 {
Text("\(markedCount)")
.font(.caption.monospacedDigit())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red, in: Capsule())
}
}
.padding(.vertical, 3)
}
}
@@ -0,0 +1,91 @@
import AppKit
import SwiftUI
struct PhotoTileView: View {
let member: SimilarityGroup.Member
let isRepresentative: Bool
let isMarked: Bool
let canMark: Bool
let onToggle: () -> Void
private let thumbnailSize: CGFloat = 168
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .topLeading) {
ThumbnailView(url: member.id, size: thumbnailSize)
.overlay {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(borderColor, lineWidth: isMarked ? 3 : 1)
}
.opacity(isMarked ? 0.55 : 1)
Toggle(isOn: Binding(get: { isMarked }, set: { _ in onToggle() })) {
EmptyView()
}
.toggleStyle(.checkbox)
.labelsHidden()
.disabled(!canMark)
// 明るい写真の上でもチェックボックスが埋もれないように下敷きを敷く
.padding(4)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 5, style: .continuous))
.padding(6)
.help(canMark ? "ゴミ箱へ移動する対象にする" : "グループから最低1枚は残す必要があります")
if isRepresentative {
Text("残す候補")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor, in: Capsule())
.foregroundStyle(.white)
.padding(6)
.frame(maxWidth: thumbnailSize, alignment: .topTrailing)
}
}
Text(member.asset.fileName)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
VStack(alignment: .leading, spacing: 1) {
Text("\(member.asset.dimensionText)・\(member.asset.fileSizeText)")
Text(member.asset.captureDateText)
if isRepresentative {
Text("この写真が基準")
} else if let tag = member.exactSetTag {
// 代表とは別だが、同じ印どうしは中身が完全に一致している
Text("\(member.relation.distanceText)・同一 \(tag)")
.foregroundStyle(.orange)
} else {
Text(member.relation.distanceText)
.foregroundStyle(member.relation.isExact ? .orange : .secondary)
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(width: thumbnailSize)
.contentShape(.rect)
// クリックは選択に一本化する。ダブルクリックを併置すると単クリックと取り合いになり、
// チェックが二度切り替わってしまうため、プレビューは右クリックメニューから開く。
.onTapGesture { if canMark { onToggle() } }
.contextMenu {
Button("プレビューで開く") { NSWorkspace.shared.open(member.id) }
Button("Finder で表示") {
NSWorkspace.shared.activateFileViewerSelecting([member.id])
}
Button("パスをコピー") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(member.id.path, forType: .string)
}
}
}
private var borderColor: Color {
if isMarked { return .red }
return isRepresentative ? .accentColor : Color.primary.opacity(0.12)
}
}
@@ -0,0 +1,29 @@
import SwiftUI
struct ScanProgressView: View {
let progress: ScanProgress
let onCancel: () -> Void
var body: some View {
VStack(spacing: 14) {
if let fraction = progress.fractionCompleted {
ProgressView(value: fraction) {
Text(progress.phase.label)
} currentValueLabel: {
Text(progress.detailText)
.monospacedDigit()
}
} else {
ProgressView {
Text(progress.phase.label)
}
}
Button("中止", role: .cancel, action: onCancel)
}
.frame(width: 300)
.padding(24)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.shadow(radius: 20)
}
}
@@ -0,0 +1,35 @@
import SwiftUI
/// QuickLook のサムネイルを非同期で読み込んで表示する。
struct ThumbnailView: View {
let url: URL
let size: CGFloat
@State private var image: NSImage?
/// 読み込み済みならキャッシュから直接描く。プレースホルダの一瞬のちらつきを防ぐ。
private var displayImage: NSImage? {
image ?? ThumbnailLoader.shared.cached(for: url, size: size)
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(.quaternary)
if let image = displayImage {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
ProgressView()
.controlSize(.small)
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.task(id: url) {
image = await ThumbnailLoader.shared.thumbnail(for: url, size: size)
}
}
}