diff options
Diffstat (limited to 'photo-compare.rb')
| -rw-r--r-- | photo-compare.rb | 186 |
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([]) |
