summaryrefslogtreecommitdiff
path: root/photo-compare.rb
blob: 4f5ec4cb94a708a5dee75ba880c64ae27509d7a6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env ruby
# frozen_string_literal: true

# photo-compare.rb — Side-by-side before/after photo comparison and selection tool.
#
# Shows each original + enhanced pair side by side, filling the window.
# Press O to move the original to --outdir, E to move the enhanced version,
# Space/S to skip. Rescans after each action so newly finished photos appear.
#
# Usage:
#   ruby photo-compare.rb --indir ~/Downloads/fuji --outdir ~/Downloads/fuji/selected
#
# Keyboard shortcuts:
#   O        — move original to outdir
#   E        — move enhanced to outdir
#   Space/S  — skip (leave both, advance to next)
#   Q/Escape — quit

require 'gtk4'
require 'optparse'
require 'fileutils'

SUPPORTED_EXTENSIONS = %w[.jpg .jpeg .png .webp].freeze

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def find_pairs(indir)
  Dir.glob(File.join(indir, '*'))
     .select { |f| File.file?(f) && SUPPORTED_EXTENSIONS.include?(File.extname(f).downcase) }
     .reject { |f| File.basename(f, '.*').end_with?('_e') }
     .reject { |f| File.basename(f).include?('.orient.') }
     .filter_map do |orig|
       ext  = File.extname(orig).downcase  # enhanced files always have lowercase ext
       base = File.basename(orig, File.extname(orig))
       enh  = File.join(File.dirname(orig), "#{base}_e#{ext}")
       [orig, enh] if File.exist?(enh)
     end
     .sort
end

def kb(path)
  (File.size(path) / 1024.0).round
end

# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

options = { indir: nil, outdir: nil }
OptionParser.new do |o|
  o.banner = 'Usage: ruby photo-compare.rb --indir DIR --outdir DIR'
  o.on('--indir PATH',  'Directory with original + _e photo pairs') { |v| options[:indir]  = v }
  o.on('--outdir PATH', 'Directory to move selected photos into')   { |v| options[:outdir] = v }
  o.on('-h', '--help', 'Show this help') { puts o; exit }
end.parse!

abort '--indir is required'  unless options[:indir]
abort '--outdir is required' unless options[:outdir]

indir  = File.expand_path(options[:indir])
outdir = File.expand_path(options[:outdir])
FileUtils.mkdir_p(outdir)

state = { pairs: find_pairs(indir), index: 0, indir: indir, outdir: outdir }
abort "No before/after pairs found in #{indir}" if state[:pairs].empty?

# ---------------------------------------------------------------------------
# GTK4 UI
# ---------------------------------------------------------------------------

app = Gtk::Application.new('org.hypr.photo-compare', :default_flags)

app.signal_connect('activate') do |a|
  win = Gtk::ApplicationWindow.new(a)
  win.title = 'Photo Compare'
  win.maximize  # fill the screen

  root = Gtk::Box.new(:vertical, 4)
  root.margin_top = root.margin_bottom = root.margin_start = root.margin_end = 6
  win.child = root

  # Top: progress info
  progress_lbl = Gtk::Label.new
  progress_lbl.xalign = 0
  root.append(progress_lbl)

  # Middle: two pictures side by side — Gtk::Picture scales to fill its container
  img_row      = Gtk::Box.new(:horizontal, 8)
  img_row.vexpand = true
  root.append(img_row)

  left_frame  = Gtk::Box.new(:vertical, 2)
  right_frame = Gtk::Box.new(:vertical, 2)
  left_frame.hexpand = right_frame.hexpand = true
  left_frame.vexpand = right_frame.vexpand = true

  # Gtk::Picture is GTK4's scaling image widget; content_fit: :contain keeps aspect ratio
  left_pic  = Gtk::Picture.new
  right_pic = Gtk::Picture.new
  left_pic.content_fit  = :contain
  right_pic.content_fit = :contain
  left_pic.hexpand  = left_pic.vexpand  = true
  right_pic.hexpand = right_pic.vexpand = true

  left_lbl  = Gtk::Label.new
  right_lbl = Gtk::Label.new

  left_frame.append(left_pic)
  left_frame.append(left_lbl)
  right_frame.append(right_pic)
  right_frame.append(right_lbl)
  img_row.append(left_frame)
  img_row.append(right_frame)

  # Bottom: action buttons
  btn_row  = Gtk::Box.new(:horizontal, 16)
  btn_row.halign = :center
  orig_btn = Gtk::Button.new(label: '← Original  [O]')
  skip_btn = Gtk::Button.new(label: 'Skip  [Space]')
  enh_btn  = Gtk::Button.new(label: 'Enhanced →  [E]')
  btn_row.append(orig_btn)
  btn_row.append(skip_btn)
  btn_row.append(enh_btn)
  root.append(btn_row)

  # -----------------------------------------------------------------------
  # Refresh display for current pair
  # -----------------------------------------------------------------------
  refresh = lambda do
    orig, enh = state[:pairs][state[:index]]
    progress_lbl.label = "#{state[:index] + 1} / #{state[:pairs].length}  —  #{File.basename(orig)}"
    left_pic.set_filename(orig)
    right_pic.set_filename(enh)
    left_lbl.label  = "Original (#{kb(orig)} KB)"
    right_lbl.label = "Enhanced (#{kb(enh)} KB)"
  end

  # -----------------------------------------------------------------------
  # After moving (or skipping), rescan and show next pair.
  # Moving removes the pair from the list, so index stays put and naturally
  # points at the next pair. Skip increments the index explicitly.
  # -----------------------------------------------------------------------
  advance = lambda do |pick|
    unless pick.nil?
      FileUtils.mv(pick, File.join(state[:outdir], File.basename(pick)))
    else
      state[:index] += 1
    end

    state[:pairs] = find_pairs(state[:indir])

    if state[:index] >= state[:pairs].length
      progress_lbl.label = 'All pairs reviewed — you can close the window.'
      left_pic.set_filename(nil)
      right_pic.set_filename(nil)
      left_lbl.label = right_lbl.label = ''
      [orig_btn, skip_btn, enh_btn].each { |b| b.sensitive = false }
    else
      refresh.call
    end
  end

  orig_btn.signal_connect('clicked') { advance.call(state[:pairs][state[:index]][0]) }
  enh_btn.signal_connect('clicked')  { advance.call(state[:pairs][state[:index]][1]) }
  skip_btn.signal_connect('clicked') { advance.call(nil) }

  key_ctrl = Gtk::EventControllerKey.new
  key_ctrl.signal_connect('key-pressed') do |_ctrl, keyval, _code, _mod|
    case keyval
    when Gdk::Keyval::KEY_o, Gdk::Keyval::KEY_O      then orig_btn.emit('clicked')
    when Gdk::Keyval::KEY_e, Gdk::Keyval::KEY_E      then enh_btn.emit('clicked')
    when Gdk::Keyval::KEY_s, Gdk::Keyval::KEY_S,
         Gdk::Keyval::KEY_space                      then skip_btn.emit('clicked')
    when Gdk::Keyval::KEY_q, Gdk::Keyval::KEY_Escape then a.quit
    end
    false
  end
  win.add_controller(key_ctrl)

  refresh.call
  win.show
end

exit app.run([])