summaryrefslogtreecommitdiff
path: root/photo-compare.rb
diff options
context:
space:
mode:
Diffstat (limited to 'photo-compare.rb')
-rw-r--r--photo-compare.rb186
1 files changed, 186 insertions, 0 deletions
diff --git a/photo-compare.rb b/photo-compare.rb
new file mode 100644
index 0000000..4f5ec4c
--- /dev/null
+++ b/photo-compare.rb
@@ -0,0 +1,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([])