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,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user