1
0
Fork 0
forked from OpenNeo/impress

Add basic image generation route /outfits/new.png

This commit is contained in:
Emi Matchu 2026-01-03 11:40:50 -08:00
parent f823bac717
commit 55fa50c22a
9 changed files with 306 additions and 7 deletions

View file

@ -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'

View file

@ -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)

View file

@ -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])

View file

@ -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

View file

@ -191,6 +191,7 @@
name:
- libmysqlclient-dev
- libyaml-dev
- libvips-dev
- name: Create the app folder
file:

View 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

View 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

Binary file not shown.

BIN
vendor/cache/ruby-vips-2.3.0.gem vendored Normal file

Binary file not shown.