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 "thread-local", "~> 1.1", require: false
|
||||
|
||||
# For image processing (outfit PNG rendering).
|
||||
gem "ruby-vips", "~> 2.2"
|
||||
|
||||
# For debugging.
|
||||
group :development do
|
||||
gem 'debug', '~> 1.9.2'
|
||||
|
|
|
|||
|
|
@ -245,7 +245,6 @@ GEM
|
|||
memory_profiler (1.1.0)
|
||||
metrics (0.15.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
|
|
@ -263,9 +262,6 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.10)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm64-darwin)
|
||||
|
|
@ -431,6 +427,9 @@ GEM
|
|||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
samovar (2.4.1)
|
||||
console (~> 1.0)
|
||||
mapping (~> 1.0)
|
||||
|
|
@ -541,7 +540,6 @@ GEM
|
|||
PLATFORMS
|
||||
aarch64-linux
|
||||
arm64-darwin
|
||||
ruby
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
|
|
@ -571,6 +569,7 @@ DEPENDENCIES
|
|||
rails-i18n (~> 8.0, >= 8.0.1)
|
||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||
rspec-rails (~> 7.0)
|
||||
ruby-vips (~> 2.2)
|
||||
sanitize (~> 6.0, >= 6.0.2)
|
||||
sass-rails (~> 6.0)
|
||||
sentry-rails (~> 5.12)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,26 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
def index
|
||||
|
|
@ -117,6 +136,40 @@ class OutfitsController < ApplicationController
|
|||
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
||||
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
|
||||
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
||||
@outfit = current_user.outfits.find(params[:id])
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ class Outfit < ApplicationRecord
|
|||
end
|
||||
|
||||
def wardrobe_params
|
||||
{
|
||||
params = {
|
||||
name: name,
|
||||
color: color_id,
|
||||
species: species_id,
|
||||
|
|
@ -269,6 +269,8 @@ class Outfit < ApplicationRecord
|
|||
objects: worn_item_ids,
|
||||
closet: closeted_item_ids,
|
||||
}
|
||||
params[:style] = alt_style_id if alt_style_id.present?
|
||||
params
|
||||
end
|
||||
|
||||
def ensure_unique_name
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@
|
|||
name:
|
||||
- libmysqlclient-dev
|
||||
- libyaml-dev
|
||||
- libvips-dev
|
||||
|
||||
- name: Create the app folder
|
||||
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