diff --git a/Gemfile b/Gemfile index 929c9148..c46ffd36 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index b9aab7ba..65073de0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/outfits_controller.rb b/app/controllers/outfits_controller.rb index 97ca9cb8..22e84a2b 100644 --- a/app/controllers/outfits_controller.rb +++ b/app/controllers/outfits_controller.rb @@ -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]) diff --git a/app/models/outfit.rb b/app/models/outfit.rb index 714f15d2..162536c7 100644 --- a/app/models/outfit.rb +++ b/app/models/outfit.rb @@ -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 diff --git a/deploy/setup.yml b/deploy/setup.yml index d6b59878..43745812 100644 --- a/deploy/setup.yml +++ b/deploy/setup.yml @@ -191,6 +191,7 @@ name: - libmysqlclient-dev - libyaml-dev + - libvips-dev - name: Create the app folder file: diff --git a/lib/outfit_image_renderer.rb b/lib/outfit_image_renderer.rb new file mode 100644 index 00000000..5bf1f135 --- /dev/null +++ b/lib/outfit_image_renderer.rb @@ -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 diff --git a/spec/lib/outfit_image_renderer_spec.rb b/spec/lib/outfit_image_renderer_spec.rb new file mode 100644 index 00000000..29b54c6b --- /dev/null +++ b/spec/lib/outfit_image_renderer_spec.rb @@ -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 diff --git a/vendor/cache/mini_portile2-2.8.9.gem b/vendor/cache/mini_portile2-2.8.9.gem deleted file mode 100644 index f90f71bf..00000000 Binary files a/vendor/cache/mini_portile2-2.8.9.gem and /dev/null differ diff --git a/vendor/cache/ruby-vips-2.3.0.gem b/vendor/cache/ruby-vips-2.3.0.gem new file mode 100644 index 00000000..d93bdb41 Binary files /dev/null and b/vendor/cache/ruby-vips-2.3.0.gem differ