|
#!/usr/bin/env ruby |
|
# frozen_string_literal: true |
|
# |
|
# IMPORTANT ENVIRONMENT SETUP: |
|
# 1. Install required gems: |
|
# gem install --user-install ruby-vips tty-prompt |
|
# |
|
# 2. Set Ruby environment variables: |
|
# export GEM_HOME=$HOME/.gem/ruby/3.3 |
|
# export GEM_PATH=$HOME/.gem/ruby/3.3:$GEM_PATH |
|
# export PATH=$HOME/.gem/ruby/3.3/bin:$PATH |
|
# |
|
# 3. CRITICAL: Ensure libvips shared libraries are in your library path: |
|
# export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH |
|
# |
|
# This is a LEGACY-COMPATIBLE version for very old libvips installations. |
|
|
|
require "vips" |
|
require "logger" |
|
require "tty-prompt" |
|
require "json" |
|
require "time" |
|
|
|
# Setup logging |
|
$logger = Logger.new("postpro.log") |
|
$logger.level = Logger::INFO |
|
$cli_logger = Logger.new(STDOUT) |
|
$cli_logger.level = Logger::INFO |
|
|
|
PROMPT = TTY::Prompt.new |
|
|
|
# Map effect names to their method symbols - only include guaranteed compatible effects |
|
EFFECTS = { |
|
film_grain: :film_grain, |
|
light_leaks: :light_leaks, |
|
golden_hour_glow: :golden_hour_glow, |
|
lomo: :lomo, |
|
anamorphic_simulation: :anamorphic_simulation, |
|
cross_process_simple: :cross_process_simple |
|
} |
|
|
|
# Returns a random array of effect keys - limit to compatible effects |
|
def random_effects(count) |
|
EFFECTS.keys.sample([count, EFFECTS.keys.size].min) |
|
end |
|
|
|
# Adjusts the intensity based on image dimensions |
|
def adjust_intensity(image, base_intensity) |
|
size_factor = Math.sqrt(image.width * image.height) / 1000.0 |
|
# Limit without using clamp |
|
intensity = base_intensity * size_factor |
|
intensity = 0.5 if intensity < 0.5 |
|
intensity = 2.0 if intensity > 2.0 |
|
intensity |
|
end |
|
|
|
# Apply an array of effects |
|
def apply_effects(image, effects_array) |
|
effects_array.each do |effect_name| |
|
method_sym = EFFECTS[effect_name] |
|
if respond_to?(method_sym, true) |
|
intensity = adjust_intensity(image, 1.0) |
|
$cli_logger.info "Applying effect: #{effect_name} (intensity: #{intensity.round(2)})" |
|
image = send(method_sym, image, intensity) |
|
else |
|
$logger.error "Effect method #{method_sym} not found" |
|
end |
|
end |
|
image |
|
end |
|
|
|
# Apply effects from JSON recipe |
|
def apply_effects_from_recipe(image, recipe) |
|
recipe.each do |effect, intensity| |
|
method_sym = EFFECTS[effect.to_sym] |
|
if respond_to?(method_sym, true) && EFFECTS.key?(effect.to_sym) |
|
$cli_logger.info "Applying effect from recipe: #{effect} (intensity: #{intensity})" |
|
image = send(method_sym, image, intensity.to_f) |
|
else |
|
$logger.error "Effect #{effect} not found or not compatible with this libvips version" |
|
end |
|
end |
|
image |
|
end |
|
|
|
# ----------------- VERIFIED-COMPATIBLE Effects Definitions ----------------- |
|
|
|
# Basic effects that work with very old libvips versions |
|
def film_grain(image, intensity) |
|
# Simple noise addition (works on all libvips versions) |
|
noise = Vips::Image.gaussnoise(image.width, image.height, mean: 128, sigma: 30 * intensity) |
|
# No clamp needed - just basic addition |
|
image + noise |
|
end |
|
|
|
def light_leaks(image, intensity) |
|
# Simple overlay (works on all libvips versions) |
|
overlay = Vips::Image.black(image.width, image.height) |
|
overlay = overlay.draw_circle([255 * intensity, 50 * intensity, 0], |
|
image.width / 3, image.height / 3, |
|
image.width / 4, fill: true) |
|
image.composite2(overlay, "add") |
|
end |
|
|
|
def golden_hour_glow(image, intensity) |
|
# Simple overlay (works on all libvips versions) |
|
overlay = Vips::Image.black(image.width, image.height) |
|
overlay = overlay.draw_circle([255, 200, 150], |
|
image.width / 2, image.height / 2, |
|
image.width / 3, fill: true) |
|
image.composite2(overlay, "add") |
|
end |
|
|
|
def lomo(image, intensity) |
|
# Simple multiplication with vignette (works on all libvips versions) |
|
saturated = image * (1.0 + 0.1 * intensity) |
|
vignette = Vips::Image.black(image.width, image.height) |
|
vignette = vignette.draw_circle(128, image.width / 2, image.height / 2, |
|
image.width / 2, fill: true) |
|
saturated.composite2(vignette, "multiply") |
|
end |
|
|
|
def cross_process_simple(image, intensity) |
|
# Simple brightening effect instead of inversion |
|
# This avoids any conditional logic or complex operations |
|
image * (1.0 + 0.2 * intensity) |
|
end |
|
|
|
def anamorphic_simulation(image, intensity) |
|
# Simple resize - core function available in all versions |
|
image.resize(1.0 + 0.1 * intensity, vscale: 1.0) |
|
end |
|
|
|
# ----------------- Camera Preset Functions ----------------- |
|
|
|
def load_camera_presets |
|
presets = [] |
|
|
|
# Create cameras directory if it doesn't exist |
|
Dir.mkdir("cameras") unless Dir.exist?("cameras") |
|
|
|
# Load any existing camera profiles |
|
Dir.glob("cameras/*.dcp").each do |file| |
|
begin |
|
preset = eval(File.read(file)) # Using eval as 'load' caused issues |
|
presets.concat(preset) if preset.is_a?(Array) |
|
rescue StandardError => e |
|
$cli_logger.error "Error loading preset from #{file}: #{e.message}" |
|
end |
|
end |
|
|
|
# If no presets exist, create a default one |
|
if presets.empty? |
|
default_preset = [ |
|
{effect: "film_grain", intensity: 1.0}, |
|
{effect: "light_leaks", intensity: 0.8}, |
|
{effect: "golden_hour_glow", intensity: 0.7}, |
|
{effect: "lomo", intensity: 0.9} |
|
] |
|
|
|
File.open("cameras/default.dcp", "w") do |f| |
|
f.write(default_preset.inspect) |
|
end |
|
|
|
$cli_logger.info "Created default camera preset in cameras/default.dcp" |
|
presets = default_preset |
|
end |
|
|
|
presets |
|
end |
|
|
|
def compute_average_preset(presets) |
|
totals = Hash.new(0) |
|
counts = Hash.new(0) |
|
presets.each do |hash| |
|
effect = hash[:effect] || hash["effect"] |
|
next unless effect |
|
|
|
# Convert from string to symbol with fallbacks for old effects |
|
effect_sym = case effect.to_s |
|
when "cross_process" |
|
:cross_process_simple |
|
else |
|
effect.to_sym |
|
end |
|
|
|
next unless EFFECTS.key?(effect_sym) |
|
|
|
intensity = (hash[:intensity] || hash["intensity"]).to_f rescue 0.0 |
|
totals[effect_sym.to_s] += intensity |
|
counts[effect_sym.to_s] += 1 |
|
end |
|
|
|
totals.each_with_object({}) do |(effect, total), avg| |
|
avg[effect] = counts[effect] > 0 ? total / counts[effect] : 1.0 |
|
end |
|
end |
|
|
|
def generate_camera_variation(average_preset) |
|
average_preset.each_with_object({}) do |(effect, intensity), variation| |
|
variation[effect] = intensity * (1 + (rand - 0.5) * 0.2) |
|
end |
|
end |
|
|
|
def apply_camera_preset(image, preset_variation) |
|
preset_variation.each do |effect, intensity| |
|
method_sym = EFFECTS[effect.to_sym] rescue nil |
|
next unless method_sym && respond_to?(method_sym, true) |
|
$cli_logger.info "Applying camera preset variation: #{effect} (intensity: #{intensity.round(2)})" |
|
image = send(method_sym, image, intensity) |
|
end |
|
image |
|
end |
|
|
|
# ----------------- Main Interactive Workflow ----------------- |
|
|
|
def main |
|
# Fix DOS line endings if needed |
|
if File.read(__FILE__).include?("\r\n") |
|
$cli_logger.info "Fixing Windows line endings in script..." |
|
content = File.read(__FILE__).gsub("\r\n", "\n") |
|
File.write("#{__FILE__}.unix", content) |
|
system("mv #{__FILE__}.unix #{__FILE__} && ruby #{__FILE__}") |
|
exit |
|
end |
|
|
|
# Check libvips version |
|
begin |
|
version = Vips::version(0) |
|
$cli_logger.info "Successfully verified libvips #{version} (legacy compatibility mode)" |
|
$cli_logger.info "WARNING: You are using a very old version of libvips with limited functionality" |
|
rescue LoadError, StandardError => e |
|
$cli_logger.error "ERROR: Failed to access libvips: #{e.message}" |
|
$cli_logger.error "Please ensure libvips is installed and LD_LIBRARY_PATH includes its location" |
|
$cli_logger.error "On OpenBSD: export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH" |
|
return |
|
end |
|
|
|
use_camera_presets = PROMPT.yes?("Apply camera preset-based variations? (Otherwise, use standard effects)") |
|
|
|
recipe = nil |
|
apply_random = true # Default to random for non-camera preset mode |
|
|
|
if !use_camera_presets |
|
apply_random = PROMPT.yes?("Apply a random combination of effects?") |
|
if !apply_random |
|
recipe_file = PROMPT.ask("Load a custom effects recipe JSON file? (Leave blank to skip)", default: "").strip |
|
if !recipe_file.empty? && File.exist?(recipe_file) |
|
recipe = JSON.parse(File.read(recipe_file)) |
|
$cli_logger.info "Loaded recipe from #{recipe_file}" |
|
else |
|
# If no recipe file and not random, fall back to random |
|
apply_random = true |
|
$cli_logger.info "No recipe file provided or found, falling back to random effects" |
|
end |
|
end |
|
end |
|
|
|
file_patterns_input = PROMPT.ask("Enter file patterns separated by commas (default: **/*.jpg, **/*.jpeg, **/*.png, **/*.webp):", default: "").strip |
|
file_patterns = file_patterns_input.empty? ? ["**/*.jpg", "**/*.jpeg", "**/*.png", "**/*.webp"] : file_patterns_input.split(",").map(&:strip) |
|
variations = PROMPT.ask("How many variations per image? (default: 3):", convert: :int, default: "3").to_i |
|
|
|
$cli_logger.info "Starting image processing..." |
|
files = file_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq |
|
if files.empty? |
|
$cli_logger.error "No files matched the specified patterns." |
|
return |
|
end |
|
|
|
average_preset = nil |
|
if use_camera_presets |
|
presets = load_camera_presets |
|
average_preset = compute_average_preset(presets) |
|
$cli_logger.info "Using camera preset: #{average_preset}" |
|
end |
|
|
|
$cli_logger.info "Found #{files.count} files to process" |
|
processed_count = 0 |
|
|
|
files.each do |file| |
|
next if file.include?("processed") |
|
begin |
|
$cli_logger.info "Processing file: #{file}" |
|
image = Vips::Image.new_from_file(file) |
|
|
|
# Process the image based on selected method |
|
processed_image = if use_camera_presets |
|
preset_variation = generate_camera_variation(average_preset) |
|
apply_camera_preset(image, preset_variation) |
|
elsif recipe |
|
apply_effects_from_recipe(image, recipe) |
|
elsif apply_random |
|
selected = random_effects(2) # Limit to 2 effects for compatibility |
|
apply_effects(image, selected) |
|
else |
|
$cli_logger.warn "No effects selected for file: #{file}, skipping." |
|
next |
|
end |
|
|
|
# Create output variations |
|
variations.times do |i| |
|
timestamp = Time.now.strftime("%Y%m%d%H%M%S") |
|
output_file = file.sub(File.extname(file), "_processed_v#{i + 1}_#{timestamp}#{File.extname(file)}") |
|
processed_image.write_to_file(output_file) |
|
$cli_logger.info "Saved variation #{i + 1} as #{output_file}" |
|
end |
|
|
|
processed_count += 1 |
|
rescue StandardError => e |
|
$cli_logger.error "Error processing file #{file}: #{e.message}" |
|
$logger.error(e.backtrace.join("\n")) |
|
end |
|
end |
|
|
|
$cli_logger.info "Image processing completed: #{processed_count} files processed successfully." |
|
end |
|
|
|
main if __FILE__ == $0 |
|
|
|
# EOF (323 lines) |
|
# CHECKSUM: sha256:57d5da25f38df5b8cb6521c9d2d7a5cbaf013f501ebab941c59803a1a4a8fe88 |