292 lines
7.2 KiB
Ruby
292 lines
7.2 KiB
Ruby
#!/usr/bin/env ruby
|
|
# app.rb — Simple Ruby/Sinatra Image → JPEG converter
|
|
# Supports:
|
|
# - JPEG quality slider
|
|
# - CRT/scanline retro effect
|
|
# - Pixelation (with user slider)
|
|
#
|
|
# Requires:
|
|
# gem install sinatra sinatra-contrib mini_magick rackup puma
|
|
# sudo apt install imagemagick
|
|
#
|
|
# Run:
|
|
# bundle exec ruby app.rb
|
|
#
|
|
# Then open: http://localhost:4567/
|
|
|
|
require "sinatra"
|
|
require "sinatra/reloader" if development?
|
|
require "mini_magick"
|
|
require "fileutils"
|
|
require "securerandom"
|
|
|
|
set :bind, "0.0.0.0"
|
|
set :port, 4567
|
|
set :public_folder, File.join(__dir__, "public")
|
|
|
|
OUTPUT_DIR = File.join(settings.public_folder, "output")
|
|
FileUtils.mkdir_p OUTPUT_DIR
|
|
|
|
# -------------------------------------------------------------------
|
|
# HELPERS — Image effects + utilities
|
|
# -------------------------------------------------------------------
|
|
helpers do
|
|
# -----------------------------------------------------------------
|
|
# CRT / Scanline effect
|
|
# -----------------------------------------------------------------
|
|
def apply_crt_effect(image)
|
|
image.combine_options do |c|
|
|
c.resize "640x480"
|
|
c.contrast
|
|
c.noise "2"
|
|
end
|
|
|
|
scanlines_path = File.join(settings.public_folder, "scanlines.png")
|
|
if File.exist?(scanlines_path)
|
|
scan = MiniMagick::Image.open(scanlines_path)
|
|
scan.resize "#{image.width}x#{image.height}!"
|
|
image = image.composite(scan) do |c|
|
|
c.compose "overlay"
|
|
c.gravity "center"
|
|
end
|
|
end
|
|
|
|
image
|
|
end
|
|
|
|
# -----------------------------------------------------------------
|
|
# Pixelation effect with user-adjustable factor
|
|
# -----------------------------------------------------------------
|
|
def apply_pixelate_effect(image, factor = 8)
|
|
width = image.width
|
|
height = image.height
|
|
|
|
small_w = [width / factor, 1].max
|
|
small_h = [height / factor, 1].max
|
|
|
|
image.combine_options do |c|
|
|
c.filter "point"
|
|
c.resize "#{small_w}x#{small_h}!"
|
|
c.resize "#{width}x#{height}!"
|
|
end
|
|
|
|
image
|
|
end
|
|
|
|
# Human-readable formatting
|
|
def human_size(bytes)
|
|
kb = bytes / 1024.0
|
|
return "#{kb.round(1)} KB" if kb < 1024
|
|
mb = kb / 1024.0
|
|
"#{mb.round(2)} MB"
|
|
end
|
|
end
|
|
|
|
# -------------------------------------------------------------------
|
|
# ROUTES
|
|
# -------------------------------------------------------------------
|
|
get "/" do
|
|
erb :index
|
|
end
|
|
|
|
post "/convert" do
|
|
unless params[:image] && params[:image][:tempfile]
|
|
@error = "Please choose an image file."
|
|
return erb :index
|
|
end
|
|
|
|
tempfile = params[:image][:tempfile]
|
|
filename = params[:image][:filename]
|
|
quality = (params[:quality] || "80").to_i
|
|
effect = params[:effect]
|
|
|
|
quality = 1 if quality < 1
|
|
quality = 100 if quality > 100
|
|
|
|
begin
|
|
image = MiniMagick::Image.open(tempfile.path)
|
|
|
|
# ------------------------------
|
|
# Apply effects
|
|
# ------------------------------
|
|
case effect
|
|
when "crt"
|
|
image = apply_crt_effect(image)
|
|
@effect_used = "CRT / scanline"
|
|
|
|
when "pixel"
|
|
factor = (params[:pixel_factor] || 8).to_i
|
|
factor = 2 if factor < 2
|
|
factor = 25 if factor > 25
|
|
image = apply_pixelate_effect(image, factor)
|
|
@effect_used = "Pixelated (factor #{factor})"
|
|
|
|
else
|
|
@effect_used = "None"
|
|
end
|
|
|
|
# Convert to JPEG
|
|
image.format "jpg"
|
|
image.quality quality.to_s
|
|
|
|
output_name = "converted-#{SecureRandom.hex(8)}.jpg"
|
|
output_path = File.join(OUTPUT_DIR, output_name)
|
|
image.write(output_path)
|
|
|
|
@original_name = filename
|
|
@output_url = "/output/#{output_name}"
|
|
@output_size = human_size(File.size(output_path))
|
|
@chosen_quality = quality
|
|
|
|
erb :result
|
|
|
|
rescue => e
|
|
@error = "Conversion failed: #{e.message}"
|
|
erb :index
|
|
end
|
|
end
|
|
|
|
# -------------------------------------------------------------------
|
|
# INLINE HTML TEMPLATES
|
|
# -------------------------------------------------------------------
|
|
__END__
|
|
|
|
@@layout
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
<title>Rubymagick JPG Converter</title>
|
|
<style>
|
|
body {
|
|
background: #111;
|
|
color: #eee;
|
|
font-family: system-ui, sans-serif;
|
|
max-width: 800px;
|
|
margin: 2rem auto;
|
|
padding: 0 1rem;
|
|
}
|
|
h1, h2 { color: #6cf; }
|
|
.card {
|
|
background: #1b1b1b;
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
box-shadow: 0 0 15px rgba(0,0,0,0.6);
|
|
border: 1px solid #333;
|
|
}
|
|
label {
|
|
display: block;
|
|
margin-top: 1rem;
|
|
margin-bottom: 0.25rem;
|
|
font-weight: 600;
|
|
}
|
|
input[type="file"],
|
|
select,
|
|
button,
|
|
input[type="range"] {
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
border-radius: 4px;
|
|
border: 1px solid #333;
|
|
background: #222;
|
|
color: #eee;
|
|
}
|
|
button {
|
|
margin-top: 1.5rem;
|
|
background: #0a84ff;
|
|
cursor: pointer;
|
|
border: none;
|
|
font-weight: 600;
|
|
}
|
|
button:hover { background: #0062cc; }
|
|
img.preview {
|
|
max-width: 100%;
|
|
border-radius: 8px;
|
|
margin-top: 1rem;
|
|
border: 1px solid #333;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>♦️ Image → JPEG Converter</h1>
|
|
<%= yield %>
|
|
</body>
|
|
</html>
|
|
|
|
@@index
|
|
<div class="card">
|
|
<% if @error %><div class="error"><%= @error %></div><% end %>
|
|
|
|
<form action="/convert" method="post" enctype="multipart/form-data">
|
|
<label for="image">Image file</label>
|
|
<input type="file" id="image" name="image" accept="image/*" required>
|
|
|
|
<label for="quality">JPEG quality:
|
|
<span id="qualityValue">80</span>
|
|
</label>
|
|
<input
|
|
type="range"
|
|
id="quality"
|
|
name="quality"
|
|
min="10"
|
|
max="100"
|
|
value="80"
|
|
oninput="document.getElementById('qualityValue').textContent = this.value"
|
|
>
|
|
|
|
<label for="effect">Effect</label>
|
|
<select id="effect" name="effect">
|
|
<option value="none">None</option>
|
|
<option value="crt">CRT / Scanline</option>
|
|
<option value="pixel">Pixelated</option>
|
|
</select>
|
|
|
|
<!-- Pixelation slider (hidden until selected) -->
|
|
<div id="pixelControls" style="display:none;">
|
|
<label for="pixel_factor">Pixelation intensity:
|
|
<span id="pixelFactorValue">8</span>
|
|
</label>
|
|
<input
|
|
type="range"
|
|
id="pixel_factor"
|
|
name="pixel_factor"
|
|
min="2"
|
|
max="25"
|
|
value="8"
|
|
oninput="document.getElementById('pixelFactorValue').textContent = this.value"
|
|
>
|
|
</div>
|
|
|
|
<script>
|
|
const effectSelect = document.getElementById("effect");
|
|
const pixelControls = document.getElementById("pixelControls");
|
|
|
|
effectSelect.addEventListener("change", () => {
|
|
pixelControls.style.display =
|
|
effectSelect.value === "pixel" ? "block" : "none";
|
|
});
|
|
</script>
|
|
|
|
<button type="submit">Convert to .jpg</button>
|
|
</form>
|
|
</div>
|
|
|
|
@@result
|
|
<div class="card">
|
|
<h2>Conversion complete</h2>
|
|
|
|
<p>
|
|
Original: <strong><%= @original_name %></strong><br>
|
|
Quality: <strong><%= @chosen_quality %></strong><br>
|
|
Effect: <strong><%= @effect_used %></strong><br>
|
|
Output size: <strong><%= @output_size %></strong>
|
|
</p>
|
|
|
|
<img class="preview" src="<%= @output_url %>" alt="Converted image preview">
|
|
|
|
<br>
|
|
<a class="download" href="<%= @output_url %>" download>Download JPEG</a>
|
|
</div>
|
|
|