forked from OpenNeo/impress
Add basic image generation route /outfits/new.png
This commit is contained in:
parent
f823bac717
commit
55fa50c22a
9 changed files with 306 additions and 7 deletions
3
Gemfile
3
Gemfile
|
|
@ -61,6 +61,9 @@ gem "async", "~> 2.17", require: false
|
||||||
gem "async-http", "~> 0.89.0", require: false
|
gem "async-http", "~> 0.89.0", require: false
|
||||||
gem "thread-local", "~> 1.1", require: false
|
gem "thread-local", "~> 1.1", require: false
|
||||||
|
|
||||||
|
# For image processing (outfit PNG rendering).
|
||||||
|
gem "ruby-vips", "~> 2.2"
|
||||||
|
|
||||||
# For debugging.
|
# For debugging.
|
||||||
group :development do
|
group :development do
|
||||||
gem 'debug', '~> 1.9.2'
|
gem 'debug', '~> 1.9.2'
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,6 @@ GEM
|
||||||
memory_profiler (1.1.0)
|
memory_profiler (1.1.0)
|
||||||
metrics (0.15.0)
|
metrics (0.15.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
|
||||||
minitest (6.0.1)
|
minitest (6.0.1)
|
||||||
prism (~> 1.5)
|
prism (~> 1.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
|
|
@ -263,9 +262,6 @@ GEM
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.18.10)
|
|
||||||
mini_portile2 (~> 2.8.2)
|
|
||||||
racc (~> 1.4)
|
|
||||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-arm64-darwin)
|
nokogiri (1.18.10-arm64-darwin)
|
||||||
|
|
@ -431,6 +427,9 @@ GEM
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
|
ruby-vips (2.3.0)
|
||||||
|
ffi (~> 1.12)
|
||||||
|
logger
|
||||||
samovar (2.4.1)
|
samovar (2.4.1)
|
||||||
console (~> 1.0)
|
console (~> 1.0)
|
||||||
mapping (~> 1.0)
|
mapping (~> 1.0)
|
||||||
|
|
@ -541,7 +540,6 @@ GEM
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
arm64-darwin
|
arm64-darwin
|
||||||
ruby
|
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
|
@ -571,6 +569,7 @@ DEPENDENCIES
|
||||||
rails-i18n (~> 8.0, >= 8.0.1)
|
rails-i18n (~> 8.0, >= 8.0.1)
|
||||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||||
rspec-rails (~> 7.0)
|
rspec-rails (~> 7.0)
|
||||||
|
ruby-vips (~> 2.2)
|
||||||
sanitize (~> 6.0, >= 6.0.2)
|
sanitize (~> 6.0, >= 6.0.2)
|
||||||
sass-rails (~> 6.0)
|
sass-rails (~> 6.0)
|
||||||
sentry-rails (~> 5.12)
|
sentry-rails (~> 5.12)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,26 @@ class OutfitsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
render "outfits/edit", layout: false
|
respond_to do |format|
|
||||||
|
format.html { render "outfits/edit", layout: false }
|
||||||
|
format.png do
|
||||||
|
@outfit = build_outfit_from_wardrobe_params
|
||||||
|
if @outfit.valid?
|
||||||
|
renderer = OutfitImageRenderer.new(@outfit)
|
||||||
|
png_data = renderer.render
|
||||||
|
|
||||||
|
if png_data
|
||||||
|
send_data png_data, type: "image/png", disposition: "inline",
|
||||||
|
filename: "outfit.png"
|
||||||
|
expires_in 1.day, public: true
|
||||||
|
else
|
||||||
|
head :not_found
|
||||||
|
end
|
||||||
|
else
|
||||||
|
head :bad_request
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|
@ -117,6 +136,40 @@ class OutfitsController < ApplicationController
|
||||||
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_outfit_from_wardrobe_params
|
||||||
|
# Load items first
|
||||||
|
worn_item_ids = params[:objects] ? Array(params[:objects]).map(&:to_i) : []
|
||||||
|
closeted_item_ids = params[:closet] ? Array(params[:closet]).map(&:to_i) : []
|
||||||
|
|
||||||
|
worn_items = Item.where(id: worn_item_ids)
|
||||||
|
closeted_items = Item.where(id: closeted_item_ids)
|
||||||
|
|
||||||
|
# Build outfit with biology and items
|
||||||
|
outfit = Outfit.new(
|
||||||
|
worn_items: worn_items,
|
||||||
|
closeted_items: closeted_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set biology from species, color, and pose params
|
||||||
|
if params[:species] && params[:color] && params[:pose]
|
||||||
|
outfit.biology = {
|
||||||
|
species_id: params[:species],
|
||||||
|
color_id: params[:color],
|
||||||
|
pose: params[:pose]
|
||||||
|
}
|
||||||
|
elsif params[:state]
|
||||||
|
# Alternative: use pet_state_id directly
|
||||||
|
outfit.biology = { pet_state_id: params[:state] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set alt style if provided
|
||||||
|
if params[:style]
|
||||||
|
outfit.alt_style_id = params[:style].to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
outfit
|
||||||
|
end
|
||||||
|
|
||||||
def find_authorized_outfit
|
def find_authorized_outfit
|
||||||
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
||||||
@outfit = current_user.outfits.find(params[:id])
|
@outfit = current_user.outfits.find(params[:id])
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ class Outfit < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def wardrobe_params
|
def wardrobe_params
|
||||||
{
|
params = {
|
||||||
name: name,
|
name: name,
|
||||||
color: color_id,
|
color: color_id,
|
||||||
species: species_id,
|
species: species_id,
|
||||||
|
|
@ -269,6 +269,8 @@ class Outfit < ApplicationRecord
|
||||||
objects: worn_item_ids,
|
objects: worn_item_ids,
|
||||||
closet: closeted_item_ids,
|
closet: closeted_item_ids,
|
||||||
}
|
}
|
||||||
|
params[:style] = alt_style_id if alt_style_id.present?
|
||||||
|
params
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_unique_name
|
def ensure_unique_name
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,7 @@
|
||||||
name:
|
name:
|
||||||
- libmysqlclient-dev
|
- libmysqlclient-dev
|
||||||
- libyaml-dev
|
- libyaml-dev
|
||||||
|
- libvips-dev
|
||||||
|
|
||||||
- name: Create the app folder
|
- name: Create the app folder
|
||||||
file:
|
file:
|
||||||
|
|
|
||||||
75
lib/outfit_image_renderer.rb
Normal file
75
lib/outfit_image_renderer.rb
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
require "vips"
|
||||||
|
|
||||||
|
class OutfitImageRenderer
|
||||||
|
CANVAS_SIZE = 600
|
||||||
|
|
||||||
|
def initialize(outfit)
|
||||||
|
@outfit = outfit
|
||||||
|
end
|
||||||
|
|
||||||
|
def render
|
||||||
|
layers = @outfit.visible_layers
|
||||||
|
|
||||||
|
# Filter out layers without image URLs
|
||||||
|
layers_with_images = layers.select(&:image_url?)
|
||||||
|
|
||||||
|
return nil if layers_with_images.empty?
|
||||||
|
|
||||||
|
# Fetch all layer images in parallel
|
||||||
|
image_data_by_layer = fetch_layer_images(layers_with_images)
|
||||||
|
|
||||||
|
# Create transparent canvas in sRGB colorspace
|
||||||
|
canvas = Vips::Image.black(CANVAS_SIZE, CANVAS_SIZE, bands: 4)
|
||||||
|
canvas = canvas.new_from_image([0, 0, 0, 0])
|
||||||
|
canvas = canvas.copy(interpretation: :srgb)
|
||||||
|
|
||||||
|
# Composite each layer onto the canvas
|
||||||
|
layers_with_images.each do |layer|
|
||||||
|
image_data = image_data_by_layer[layer]
|
||||||
|
next unless image_data
|
||||||
|
|
||||||
|
begin
|
||||||
|
layer_image = Vips::Image.new_from_buffer(image_data, "")
|
||||||
|
|
||||||
|
# Center the layer on the canvas
|
||||||
|
x_offset = (CANVAS_SIZE - layer_image.width) / 2
|
||||||
|
y_offset = (CANVAS_SIZE - layer_image.height) / 2
|
||||||
|
|
||||||
|
# Composite this layer onto the canvas
|
||||||
|
canvas = canvas.composite([layer_image], :over, x: x_offset, y: y_offset)
|
||||||
|
rescue Vips::Error => e
|
||||||
|
# Log and skip layers that fail to load/composite
|
||||||
|
Rails.logger.warn "Failed to composite layer #{layer.id} (#{layer.image_url}): #{e.message}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return PNG data
|
||||||
|
canvas.write_to_buffer(".png")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_layer_images(layers)
|
||||||
|
image_data_by_layer = {}
|
||||||
|
|
||||||
|
DTIRequests.load_many(max_at_once: 10) do |semaphore|
|
||||||
|
layers.each do |layer|
|
||||||
|
semaphore.async do
|
||||||
|
begin
|
||||||
|
response = DTIRequests.get(layer.image_url)
|
||||||
|
if response.success?
|
||||||
|
image_data_by_layer[layer] = response.read
|
||||||
|
else
|
||||||
|
Rails.logger.warn "Failed to fetch image for layer #{layer.id} (#{layer.image_url}): HTTP #{response.status}"
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.warn "Error fetching image for layer #{layer.id} (#{layer.image_url}): #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
image_data_by_layer
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
166
spec/lib/outfit_image_renderer_spec.rb
Normal file
166
spec/lib/outfit_image_renderer_spec.rb
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
require 'webmock/rspec'
|
||||||
|
require_relative '../rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe OutfitImageRenderer do
|
||||||
|
fixtures :zones, :colors, :species
|
||||||
|
|
||||||
|
# Helper to create a simple PNG image (1x1 pixel) with a specific color
|
||||||
|
def create_test_png(red, green, blue, alpha = 255)
|
||||||
|
require 'vips'
|
||||||
|
image = Vips::Image.black(1, 1, bands: 4)
|
||||||
|
image = image.new_from_image([red, green, blue, alpha])
|
||||||
|
image.write_to_buffer('.png')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create a pet state with specific swf_assets
|
||||||
|
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
|
||||||
|
pet_state = PetState.create!(
|
||||||
|
pet_type: pet_type,
|
||||||
|
pose: pose,
|
||||||
|
swf_asset_ids: swf_assets.empty? ? [0] : swf_assets.map(&:id)
|
||||||
|
)
|
||||||
|
pet_state.swf_assets = swf_assets
|
||||||
|
pet_state
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create a SwfAsset for biology (pet layers)
|
||||||
|
def build_biology_asset(zone, body_id:)
|
||||||
|
@remote_id = (@remote_id || 0) + 1
|
||||||
|
SwfAsset.create!(
|
||||||
|
type: "biology",
|
||||||
|
remote_id: @remote_id,
|
||||||
|
url: "https://images.neopets.example/biology_#{@remote_id}.swf",
|
||||||
|
zone: zone,
|
||||||
|
body_id: body_id,
|
||||||
|
zones_restrict: "",
|
||||||
|
has_image: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create a SwfAsset for items (object layers)
|
||||||
|
def build_item_asset(zone, body_id:)
|
||||||
|
@remote_id = (@remote_id || 0) + 1
|
||||||
|
SwfAsset.create!(
|
||||||
|
type: "object",
|
||||||
|
remote_id: @remote_id,
|
||||||
|
url: "https://images.neopets.example/object_#{@remote_id}.swf",
|
||||||
|
zone: zone,
|
||||||
|
body_id: body_id,
|
||||||
|
zones_restrict: "",
|
||||||
|
has_image: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create an item with specific swf_assets
|
||||||
|
def build_item(name, swf_assets: [])
|
||||||
|
item = Item.create!(
|
||||||
|
name: name,
|
||||||
|
description: "Test item",
|
||||||
|
thumbnail_url: "https://images.neopets.example/thumbnail.png",
|
||||||
|
rarity: "Common",
|
||||||
|
price: 100,
|
||||||
|
zones_restrict: "",
|
||||||
|
species_support_ids: ""
|
||||||
|
)
|
||||||
|
swf_assets.each do |asset|
|
||||||
|
ParentSwfAssetRelationship.create!(
|
||||||
|
parent: item,
|
||||||
|
swf_asset: asset
|
||||||
|
)
|
||||||
|
end
|
||||||
|
item
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
PetType.destroy_all
|
||||||
|
@pet_type = PetType.create!(
|
||||||
|
species: species(:acara),
|
||||||
|
color: colors(:blue),
|
||||||
|
body_id: 1,
|
||||||
|
created_at: Time.new(2005)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#render" do
|
||||||
|
context "with a simple outfit" do
|
||||||
|
it "composites biology and item layers into a single PNG" do
|
||||||
|
# Create test PNG data
|
||||||
|
red_png = create_test_png(255, 0, 0) # Red pixel
|
||||||
|
blue_png = create_test_png(0, 0, 255) # Blue pixel
|
||||||
|
|
||||||
|
# Create biology and item assets
|
||||||
|
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||||
|
item_asset = build_item_asset(zones(:hat), body_id: 1)
|
||||||
|
|
||||||
|
# Stub HTTP requests for the actual image URLs that will be generated
|
||||||
|
stub_request(:get, biology_asset.image_url).
|
||||||
|
to_return(body: red_png, status: 200)
|
||||||
|
stub_request(:get, item_asset.image_url).
|
||||||
|
to_return(body: blue_png, status: 200)
|
||||||
|
|
||||||
|
# Build outfit
|
||||||
|
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||||
|
item = build_item("Test Hat", swf_assets: [item_asset])
|
||||||
|
outfit = Outfit.new(pet_state: pet_state)
|
||||||
|
outfit.item_ids = { worn: [item.id], closeted: [] }
|
||||||
|
|
||||||
|
# Render
|
||||||
|
renderer = OutfitImageRenderer.new(outfit)
|
||||||
|
result = renderer.render
|
||||||
|
|
||||||
|
# Verify we got PNG data back
|
||||||
|
expect(result).not_to be_nil
|
||||||
|
expect(result).to be_a(String)
|
||||||
|
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b) # PNG magic bytes
|
||||||
|
|
||||||
|
# Verify the result is a valid 600x600 PNG
|
||||||
|
image = Vips::Image.new_from_buffer(result, "")
|
||||||
|
expect(image.width).to eq(600)
|
||||||
|
expect(image.height).to eq(600)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when a layer image fails to load" do
|
||||||
|
it "skips the failed layer and continues" do
|
||||||
|
blue_png = create_test_png(0, 0, 255)
|
||||||
|
|
||||||
|
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||||
|
item_asset = build_item_asset(zones(:hat), body_id: 1)
|
||||||
|
|
||||||
|
# Stub one successful request and one failure
|
||||||
|
stub_request(:get, biology_asset.image_url).
|
||||||
|
to_return(status: 404)
|
||||||
|
stub_request(:get, item_asset.image_url).
|
||||||
|
to_return(body: blue_png, status: 200)
|
||||||
|
|
||||||
|
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||||
|
item = build_item("Test Hat", swf_assets: [item_asset])
|
||||||
|
outfit = Outfit.new(pet_state: pet_state)
|
||||||
|
outfit.item_ids = { worn: [item.id], closeted: [] }
|
||||||
|
|
||||||
|
renderer = OutfitImageRenderer.new(outfit)
|
||||||
|
result = renderer.render
|
||||||
|
|
||||||
|
# Should still render successfully with just the one layer
|
||||||
|
expect(result).not_to be_nil
|
||||||
|
expect(result[0..7]).to eq("\x89PNG\r\n\x1A\n".b)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when no layers have images" do
|
||||||
|
it "returns nil" do
|
||||||
|
# Create an asset but stub image_url to return nil
|
||||||
|
biology_asset = build_biology_asset(zones(:head), body_id: 1)
|
||||||
|
allow_any_instance_of(SwfAsset).to receive(:image_url?).and_return(false)
|
||||||
|
|
||||||
|
pet_state = build_pet_state(@pet_type, swf_assets: [biology_asset])
|
||||||
|
outfit = Outfit.new(pet_state: pet_state)
|
||||||
|
|
||||||
|
renderer = OutfitImageRenderer.new(outfit)
|
||||||
|
result = renderer.render
|
||||||
|
|
||||||
|
expect(result).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
BIN
vendor/cache/mini_portile2-2.8.9.gem
vendored
BIN
vendor/cache/mini_portile2-2.8.9.gem
vendored
Binary file not shown.
BIN
vendor/cache/ruby-vips-2.3.0.gem
vendored
Normal file
BIN
vendor/cache/ruby-vips-2.3.0.gem
vendored
Normal file
Binary file not shown.
Loading…
Reference in a new issue