Skip to content

Instantly share code, notes, and snippets.

@anon987654321
Last active January 20, 2026 22:43
Show Gist options
  • Select an option

  • Save anon987654321/3fbfd2a91b6dea91f0da8ba2c9191de4 to your computer and use it in GitHub Desktop.

Select an option

Save anon987654321/3fbfd2a91b6dea91f0da8ba2c9191de4 to your computer and use it in GitHub Desktop.

postpro.rb

Where Code Meets Celluloid Soul. postpro.rb is more than a tool—it's a translator for the forgotten language of light, chemistry, and mechanics. It captures the visceral, human feel of analog capture and applies it to digital pixels with scientific precision.

1

The Cinematic Emotion Engine. Apply physically accurate analog film emulation—grain, halation, weave—through scientific models in Ruby. Not a filter; a translator for celluloid soul.

Ruby libvips


Install

# Install the engine
gem install ruby-vips

# Transform an image (creates 'your_photo_blockbuster.jpg')
ruby postpro.rb your_photo.jpg --preset blockbuster

# Or, run interactively
ruby postpro.rb

Presets

  • portrait: Warm, intimate. Kodak Portra skin tones.
  • blockbuster: Epic, theatrical. Halation, teal/orange contrast.
  • street: Gritty, urgent. High-contrast Tri-X grain.
  • dream: Subjective, soft. Leica glow, color bleed.

Presets are defined in postpro.rb and configured via master.yml.


How It Works

A physically-based pipeline:

  1. Converts image to linear light.
  2. Applies film stock color and H&D characteristic curves.
  3. Adds optical effects (halation, bloom).
  4. Encodes for display and overlays texture (AR grain, gate weave).

This order ensures effects feel authentic, not applied.


A Final Note on the Craft

This tool is built on a simple, radical idea: that the "feel" of analog media is not a mystery, but a set of measurable, physical phenomena. By understanding the why—why halation feels romantic, why grain feels tactile, why a lifted black feels nostalgic—you gain not just a set of filters, but a director's control over time, texture, and emotion.

Postpro.rb is your lens into that control. Now go make something that feels.

#!/usr/bin/env ruby
# frozen_string_literal: true
# postpro.rb - The Cinematic Emotion Engine v15.1.0
# ACES pipeline + AR grain + Super8 weave.
# Format: 2-space indent, double-quoted strings.
require "logger"
require "json"
require "time"
require "fileutils"
# ──────────────────────────────────────────────────────────────────────────────
# Bootstrap & Configuration
# ──────────────────────────────────────────────────────────────────────────────
module PostproBootstrap
# ... [Your existing bootstrap logic remains here, formatted with 2-space indent] ...
end
BOOTSTRAP = PostproBootstrap.run
$logger = Logger.new("postpro.log", "daily", level: Logger::DEBUG)
$cli_logger = Logger.new($stdout, level: Logger::INFO)
PROMPT = TTY::Prompt.new if BOOTSTRAP[:gems][:tty]
require "vips" if BOOTSTRAP[:gems][:vips]
REPLIGEN_PRESENT = File.exist?("repligen.rb")
CAMERA_PROFILES = BOOTSTRAP[:camera_profiles]
CONFIG = BOOTSTRAP[:config]
# ──────────────────────────────────────────────────────────────────────────────
# Film Stock Database: Emotional Parameters
# ──────────────────────────────────────────────────────────────────────────────
STOCKS = {
kodak_portra: { grain: 15, gamma: 0.65, toe: 0.10, shoulder: 0.88, lift: 0.05 },
kodak_vision3_50d: { grain: 8, gamma: 0.63, toe: 0.08, shoulder: 0.92, lift: 0.02 },
kodak_vision3_500t: { grain: 20, gamma: 0.65, toe: 0.12, shoulder: 0.88, lift: 0.08, blue_shift: 0.15 },
fuji_velvia: { grain: 8, gamma: 0.75, toe: 0.05, shoulder: 0.95, lift: 0.03 },
tri_x: { grain: 25, gamma: 0.70, toe: 0.15, shoulder: 0.80, lift: 0.12 },
print_2383: { contrast: 1.1, saturation: 1.15 },
print_3510: { contrast: 1.05, saturation: 1.0 }
}.freeze
# ──────────────────────────────────────────────────────────────────────────────
# Core Math & Physics Models
# ──────────────────────────────────────────────────────────────────────────────
def to_linear(img) = img.gamma(gamma: 2.2)
def to_gamma(img) = img.gamma(gamma: 1/2.2)
def lum(img) = img.colourspace("grey16").cast("float") / 65535.0
def curve(img, stock=:kodak_portra, i=1.0)
s = STOCKS[stock]
x = ((to_linear(img).log + 3 - s[:toe]) / (s[:shoulder] - s[:toe])).clamp(0, 1)
d = x*x*(3-2*x) ** (1/s[:gamma])
to_gamma(img * (1-i) + d.linear(1, s[:lift] * 255 * i))
end
def ar_grain(img, i=0.4, stock=:kodak_portra)
s = STOCKS[stock]
n = Vips::Image.gaussnoise(img.width, img.height, sigma: s[:grain] * i * 15)
c = n.gaussblur(1.5).linear(0.6, 0) + n.linear(0.4, 0)
strength = (2 - lum(img)).clamp(0.8, 2)
g = c * strength.bandjoin([strength, strength])
to_gamma(img.composite2(g.cast("uchar") * 0.3, "soft_light"))
end
def halation(img, i=0.4)
l = to_linear(img)
h = l > 220
glow = h.gaussblur(15) * 0.5 + h.gaussblur(35) * 0.3 + h.gaussblur(70) * 0.2
c = [255 * i, 90 * i, 40 * i]
to_gamma(l.composite2(glow.ifthenelse(c, [0, 0, 0]), "screen"))
end
def super8_weave(img, i=0.3)
t = Time.now.to_f * 10
ox = simplex2d(t * 0.8, 0) * 18 * i
oy = simplex2d(0, t * 0.8) * 14 * i
rot = simplex2d(t * 0.3, t * 0.4) * 0.8 * i
c = Math.cos(rot * Math::PI / 180)
s = Math.sin(rot * Math::PI / 180)
m = [c, s, -s, c]
img.affine(m, odx: ox, ody: oy, interpolate: Vips::Interpolate.new("bicubic"))
end
def neg_print(img, neg=:kodak_vision3_500t, prt=:print_2383, i=1.0)
l = to_linear(img)
n = curve(l, neg, i)
n = n.linear(STOCKS[prt][:contrast] || 1.0, 0)
to_gamma(n)
end
def simplex2d(x, y)
n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
n - n.floor
end
# ──────────────────────────────────────────────────────────────────────────────
# Emotional Preset System: Curated Psychological Profiles
# ──────────────────────────────────────────────────────────────────────────────
EMOTIONAL_PRESETS = {
portrait: {
steps: [
{ method: :apply_vintage_lens, params: { lens_type: "zeiss", intensity: 0.8 } },
{ method: :neg_print, params: { neg_stock: :kodak_portra, print_stock: :print_2383, intensity: 1.0 } },
{ method: :base_tint, params: { tint: [255, 250, 245], intensity: 0.08 } }
],
feeling: "Intimacy, warmth, human connection. The 'honest' lens."
},
blockbuster: {
steps: [
{ method: :neg_print, params: { neg_stock: :kodak_vision3_500t, print_stock: :print_2383, intensity: 1.0 } },
{ method: :halation, params: { intensity: 0.7 } },
{ method: :teal_orange, params: { intensity: 1.1 } }
],
feeling: "Awe, spectacle, romantic scale. The 'larger than life' canvas."
},
street: {
steps: [
{ method: :apply_vintage_lens, params: { lens_type: "helios", intensity: 0.6 } },
{ method: :neg_print, params: { neg_stock: :tri_x, print_stock: :print_2383, intensity: 1.2 } },
{ method: :ar_grain, params: { intensity: 0.9, stock: :tri_x } }
],
feeling: "Immediacy, tension, documentary truth. The 'unflinching' eye."
},
dream: {
steps: [
{ method: :apply_vintage_lens, params: { lens_type: "leica", intensity: 0.9 } },
{ method: :color_bleed, params: { intensity: 0.5 } },
{ method: :shadow_lift, params: { amount: 0.25, preserve_blacks: false } }
],
feeling: "Memory, reverie, soft focus. The 'subjective inner world'."
}
}.freeze
def apply_emotional_preset(image, preset_name: :portrait)
preset = EMOTIONAL_PRESETS[preset_name]
return image unless preset
$cli_logger.info "Applying '#{preset_name}' preset: #{preset[:feeling]}"
result = image
preset[:steps].each do |step|
if respond_to?(step[:method])
result = send(step[:method], result, **step[:params])
else
$logger.warn "Method #{step[:method]} not found. Skipping."
end
end
result
end
# ──────────────────────────────────────────────────────────────────────────────
# Original FX Library (Formatted for consistency)
# ──────────────────────────────────────────────────────────────────────────────
def apply_vintage_lens(image, lens_type: "zeiss", intensity: 0.7)
case lens_type
when "zeiss"
micro_contrast(image, radius: 3, intensity: 0.4 * intensity)
when "helios"
sharp = image.sharpen(mask: [[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
image * (1 - intensity * 0.3) + sharp * (intensity * 0.3)
when "leica"
glow = image.gaussblur(20).linear([0.3 * intensity], [0])
image + glow
else
image
end
end
def base_tint(image, tint_color, intensity=0.1)
tint_rgb = tint_color.is_a?(Array) ? tint_color : [tint_color, tint_color, tint_color]
tint_layer = Vips::Image.black(image.width, image.height, bands: 3) + tint_rgb
image * (1.0 - intensity * 0.5) + tint_layer * (intensity * 0.5 / 255.0)
end
def color_bleed(image, intensity=0.3)
r, g, b = image.bandsplit
r_bleed = r.gaussblur(0.8 * intensity)
g_bleed = g.gaussblur(0.6 * intensity)
b_bleed = b.gaussblur(1.0 * intensity)
result = Vips::Image.bandjoin([r_bleed, g_bleed, b_bleed])
image * (1 - intensity) + result * intensity
end
def micro_contrast(image, radius=5, intensity=0.3)
blurred = image.gaussblur(radius)
high_pass = image - blurred
image + high_pass * intensity
end
def teal_orange(image, intensity=1.0)
protected = skin_protect(image, 0.8)
r, g, b = protected.bandsplit
r_enhanced = r.linear([1 + 0.25 * intensity], [8 * intensity])
g_balanced = g.linear([1 - 0.08 * intensity], [0])
b_enhanced = b.linear([1 + 0.35 * intensity], [0])
Vips::Image.bandjoin([r_enhanced, g_balanced, b_enhanced])
end
def shadow_lift(image, lift=0.15, preserve_blacks=true)
gray = image.colourspace("grey16").cast("float") / 255.0
shadow_mask = preserve_blacks ? ((1.0 - gray).pow(2.0)) * 0.8 : (1.0 - gray) * lift
lift_rgb = shadow_mask.bandjoin([shadow_mask, shadow_mask])
image.linear([1.0, 1.0, 1.0], [lift_rgb * 255 * lift])
end
# ──────────────────────────────────────────────────────────────────────────────
# Main Application Flow
# ──────────────────────────────────────────────────────────────────────────────
def process_file(file, variations, preset_name=nil, recipe_data=nil, random_effects=nil, mode="professional")
image = load_image(file)
return 0 unless image
if CONFIG["apply_camera_profile_first"]
profile = get_camera_profile(image)
if profile
image = apply_camera_profile(image, profile)
PostproBootstrap.log_message "Applied camera profile for #{file}"
end
end
processed_count = 0
variations.times do |i|
begin
processed = if preset_name
apply_emotional_preset(image, preset_name: preset_name.to_sym)
elsif recipe_data
recipe(image, recipe_data)
elsif random_effects
random_fx(image, random_effects, mode)
else
next
end
next unless processed
processed = rgb_bands(processed)
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
suffix = preset_name || "processed"
output = file.sub(File.extname(file), "_#{suffix}_v#{i + 1}_#{timestamp}#{File.extname(file)}")
quality = CONFIG["jpeg_quality"] || 95
processed.write_to_file(output, Q: quality)
$cli_logger.info "Saved masterpiece #{i + 1}: #{File.basename(output)}"
processed_count += 1
rescue StandardError => e
$logger.error "Variation #{i + 1} failed: #{e.message}"
end
end
processed_count
end
# ──────────────────────────────────────────────────────────────────────────────
# CLI & Execution
# ──────────────────────────────────────────────────────────────────────────────
def auto_launch
if ARGV.include?("--auto") || (!$stdin.tty? && ARGV.include?("--from-repligen"))
input = auto_mode
elsif ARGV.include?("--from-repligen") && REPLIGEN_PRESENT
check_repligen
return
else
input = get_input
end
return unless input
patterns, variations, config = input
files = patterns.flat_map { |p| Dir.glob(p) }
.reject { |f| File.basename(f).match?(/processed|masterpiece/) }
if files.empty?
$cli_logger.error "No files matched patterns!"
return
end
$cli_logger.info "Processing #{files.count} files..."
total_processed = 0
total_variations = 0
start_time = Time.now
files.each_with_index do |file, i|
begin
$cli_logger.info "#{i + 1}/#{files.count}: #{File.basename(file)}"
count = case config[:type]
when :preset
process_file(file, variations, config[:preset])
when :random
fx = %w[grain leaks sepia bloom teal_orange cross vhs chroma glitch flare]
selected = config[:mode] == "experimental" ? fx : fx.first(6)
random_effects = selected.shuffle.take(config[:fx])
process_file(file, variations, nil, nil, random_effects, config[:mode])
when :recipe
process_file(file, variations, nil, config[:recipe])
else 0
end
total_processed += 1 if count > 0
total_variations += count
GC.start if (i % 10).zero?
rescue StandardError => e
$logger.error "Failed #{file}: #{e.message}"
$cli_logger.error "Error: #{File.basename(file)}"
end
end
duration = (Time.now - start_time).round(2)
$cli_logger.info "Complete! #{total_processed} files → #{total_variations} masterpieces (#{duration}s)"
if REPLIGEN_PRESENT && total_variations > 0
$cli_logger.info "Tip: Run 'ruby repligen.rb' to generate more content!"
end
end
auto_launch if __FILE__ == $PROGRAM_NAME
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment