Implement embed images (#173)

* Add mini_magick and rufus-scheduler

* Expose attributes and add sigs to AwsService

* Get Party ready for preview state

* Added new fields for preview state and generated_at timestamp
* Add preview state enum to model
* Add preview_relevant_changes? after_commit hook

* Add jobs for generating and cleaning up party previews

* Add new endpoints to PartiesController

* `preview` shows the preview and queues it up for generation if it doesn't exist yet
* `regenerate_preview` allows the party owner to force regeneration of previews

* Schedule jobs

* Stalled jobs are checked every 5 minutes
* Failed jobs are retried every hour
* Old preview jobs are cleaned up daily

* Add the preview service

This is where the bulk of the work is. This service renders out the preview images bit by bit. Currently we render the party name, creator, job icon, and weapon grid.

This includes signatures and some fonts.
This commit is contained in:
Justin Edmund 2025-01-18 09:08:15 -08:00 committed by GitHub
parent 7ac5501da5
commit e3a44ca0d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1139 additions and 2 deletions

View file

@ -32,6 +32,9 @@ gem 'responders'
# Parse emoji to strings # Parse emoji to strings
gem 'gemoji-parser' gem 'gemoji-parser'
# Mini replacement for RMagick
gem 'mini_magick'
# An awesome replacement for acts_as_nested_set and better_nested_set. # An awesome replacement for acts_as_nested_set and better_nested_set.
gem 'awesome_nested_set' gem 'awesome_nested_set'
@ -44,6 +47,9 @@ gem 'email_validator'
# pg_search builds ActiveRecord named scopes that take advantage of PostgreSQLs full text search # pg_search builds ActiveRecord named scopes that take advantage of PostgreSQLs full text search
gem 'pg_search' gem 'pg_search'
# scheduler for Ruby (at, in, cron and every jobs)
gem 'rufus-scheduler'
# Pagination library # Pagination library
gem 'will_paginate', '~> 3.3' gem 'will_paginate', '~> 3.3'

View file

@ -139,6 +139,8 @@ GEM
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
factory_bot (6.5.0) factory_bot (6.5.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
factory_bot_rails (6.4.4) factory_bot_rails (6.4.4)
@ -156,6 +158,9 @@ GEM
ffi (1.17.1-x86_64-linux-musl) ffi (1.17.1-x86_64-linux-musl)
figaro (1.2.0) figaro (1.2.0)
thor (>= 0.14.0, < 2) thor (>= 0.14.0, < 2)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
gemoji (4.1.0) gemoji (4.1.0)
gemoji-parser (1.3.1) gemoji-parser (1.3.1)
gemoji (>= 2.1.0) gemoji (>= 2.1.0)
@ -193,6 +198,7 @@ GEM
net-smtp net-smtp
marcel (1.0.4) marcel (1.0.4)
method_source (1.1.0) method_source (1.1.0)
mini_magick (5.1.0)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.4) minitest (5.25.4)
msgpack (1.7.5) msgpack (1.7.5)
@ -244,6 +250,7 @@ GEM
stringio stringio
puma (6.5.0) puma (6.5.0)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.8) rack (3.1.8)
rack-cors (2.0.2) rack-cors (2.0.2)
@ -337,6 +344,8 @@ GEM
rubocop-ast (1.37.0) rubocop-ast (1.37.0)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
sdoc (2.6.1) sdoc (2.6.1)
rdoc (>= 5.0) rdoc (>= 5.0)
securerandom (0.4.1) securerandom (0.4.1)
@ -427,6 +436,7 @@ DEPENDENCIES
gemoji-parser gemoji-parser
httparty httparty
listen listen
mini_magick
oj oj
pg pg
pg_search pg_search
@ -438,6 +448,7 @@ DEPENDENCIES
rspec-rails rspec-rails
rspec_junit_formatter rspec_junit_formatter
rubocop rubocop
rufus-scheduler
sdoc sdoc
shoulda-matchers shoulda-matchers
simplecov simplecov

BIN
app/assets/fonts/Gk-Bd.otf Normal file

Binary file not shown.

BIN
app/assets/fonts/Gk-Rg.otf Normal file

Binary file not shown.

View file

@ -116,6 +116,30 @@ module Api
render_party_json(@parties, count, total_pages) render_party_json(@parties, count, total_pages)
end end
def preview
party = Party.find_by!(shortcode: params[:id])
preview_service = PreviewService::Coordinator.new(party)
redirect_to preview_service.preview_url
end
def regenerate_preview
party = Party.find_by!(shortcode: params[:id])
# Ensure only party owner can force regeneration
unless current_user && party.user_id == current_user.id
return render_unauthorized_response
end
preview_service = PreviewService::Coordinator.new(party)
if preview_service.force_regenerate
render json: { status: 'Preview regeneration started' }
else
render json: { error: 'Preview regeneration failed' },
status: :unprocessable_entity
end
end
private private
def authorize def authorize

View file

@ -0,0 +1,4 @@
# frozen_string_literal: true
class ApplicationJob < ActiveJob::Base
end

View file

@ -0,0 +1,11 @@
class CleanupPartyPreviewsJob < ApplicationJob
queue_as :maintenance
def perform
Party.where(preview_state: :generated)
.where('preview_generated_at < ?', PreviewService::Coordinator::PREVIEW_EXPIRY.ago)
.find_each do |party|
PreviewService::Coordinator.new(party).delete_preview
end
end
end

View file

@ -0,0 +1,75 @@
# app/jobs/generate_party_preview_job.rb
class GeneratePartyPreviewJob < ApplicationJob
queue_as :previews
# Configure retry behavior
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, error|
Rails.logger.error("Party #{job.arguments.first} not found for preview generation")
end
around_perform :track_timing
def perform(party_id)
# Log start of job processing
Rails.logger.info("Starting preview generation for party #{party_id}")
party = Party.find(party_id)
if party.preview_state == 'generated' &&
party.preview_generated_at &&
party.preview_generated_at > 1.hour.ago
Rails.logger.info("Skipping preview generation - recent preview exists")
return
end
begin
service = PreviewService::Coordinator.new(party)
result = service.generate_preview
if result
Rails.logger.info("Successfully generated preview for party #{party_id}")
else
Rails.logger.error("Failed to generate preview for party #{party_id}")
notify_failure(party)
end
rescue => e
Rails.logger.error("Error generating preview for party #{party_id}: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
notify_failure(party, e)
raise # Allow retry mechanism to handle the error
end
end
private
def track_timing
start_time = Time.current
job_id = job_id
Rails.logger.info("Preview generation job #{job_id} starting")
yield
duration = Time.current - start_time
Rails.logger.info("Preview generation job #{job_id} completed in #{duration.round(2)}s")
# Track metrics if you have a metrics service
# StatsD.timing("preview_generation.duration", duration * 1000)
end
def notify_failure(party, error = nil)
# Log to error tracking service if you have one
# Sentry.capture_exception(error) if error
# You could also notify admins through Slack/email for critical failures
message = if error
"Preview generation failed for party #{party.id} with error: #{error.message}"
else
"Preview generation failed for party #{party.id}"
end
# SlackNotifier.notify(message) # If you have Slack integration
end
end

View file

@ -105,6 +105,15 @@ class Party < ApplicationRecord
attr_accessor :favorited attr_accessor :favorited
self.enum :preview_state, {
pending: 0, # Never generated
queued: 1, # Generation job scheduled
generated: 2, # Has preview image
failed: 3 # Generation failed
}
after_commit :schedule_preview_regeneration, if: :preview_relevant_changes?
def is_favorited(user) def is_favorited(user)
user.favorite_parties.include? self if user user.favorite_parties.include? self if user
end end
@ -177,4 +186,18 @@ class Party < ApplicationRecord
errors.add(:guidebooks, 'must be unique') errors.add(:guidebooks, 'must be unique')
end end
def preview_relevant_changes?
return false if preview_state == 'queued'
(saved_changes.keys & %w[name job_id element weapons_count characters_count summons_count]).any?
end
def schedule_preview_regeneration
# Cancel any pending jobs
GeneratePartyPreviewJob.cancel_scheduled_jobs(party_id: id)
# Mark as pending
update_column(:preview_state, :pending)
end
end end

View file

@ -3,6 +3,8 @@
require 'aws-sdk-s3' require 'aws-sdk-s3'
class AwsService class AwsService
attr_reader :s3_client, :bucket
class ConfigurationError < StandardError; end class ConfigurationError < StandardError; end
def initialize def initialize

View file

@ -0,0 +1,173 @@
# app/services/canvas.rb
module PreviewService
class Canvas
PREVIEW_WIDTH = 1200
PREVIEW_HEIGHT = 630
DEFAULT_BACKGROUND_COLOR = '#1a1b1e'
# Padding and spacing constants
PADDING = 24
TITLE_IMAGE_GAP = 24
GRID_GAP = 4
def initialize(image_fetcher)
@image_fetcher = image_fetcher
end
def create_blank_canvas(width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT, color: DEFAULT_BACKGROUND_COLOR)
temp_file = Tempfile.new(%w[canvas .png])
MiniMagick::Tool::Convert.new do |convert|
convert.size "#{width}x#{height}"
convert << "xc:#{color}"
convert << temp_file.path
end
temp_file
end
def add_text(image, party_name, job_icon: nil, user: nil, **options)
font_size = options.fetch(:size, '32')
font_color = options.fetch(:color, 'white')
# Load custom font for username, for later use
@font_path ||= Rails.root.join('app', 'assets', 'fonts', 'Gk-Bd.otf').to_s
# Measure party name text size
text_metrics = measure_text(party_name, font_size)
# Draw job icon if provided
image = draw_job_icon(image, job_icon) if job_icon
# Draw party name text
image = draw_party_name(image, party_name, text_metrics, job_icon, font_color, font_size)
# Compute vertical center of the party name text line
party_text_center_y = PADDING + (text_metrics[:height] / 2.0)
# Draw user info if provided
image = draw_user_info(image, user, party_text_center_y, font_color) if user
{
image: image,
text_bottom_y: PADDING + text_metrics[:height] + TITLE_IMAGE_GAP
}
end
private
def draw_job_icon(image, job_icon)
job_icon.format("png32")
job_icon.alpha('set')
job_icon.background('none')
job_icon.combine_options do |c|
c.filter "Lanczos" # High-quality filter
c.resize "64x64"
end
image = image.composite(job_icon) do |c|
c.compose "Over"
c.geometry "+#{PADDING}+#{PADDING}"
end
image
end
def draw_party_name(image, party_name, text_metrics, job_icon, font_color, font_size)
# Determine x position based on presence of job_icon
text_x = job_icon ? PADDING + 64 + 16 : PADDING
text_y = PADDING + text_metrics[:height]
image.combine_options do |c|
c.font @font_path
c.fill font_color
c.pointsize font_size
c.draw "text #{text_x},#{text_y} '#{party_name}'"
end
image
end
def draw_user_info(image, user, party_text_center_y, font_color)
username_font_size = 24
username_font_path = @font_path
# Fetch and prepare user picture
user_picture = @image_fetcher.fetch_user_picture(user.picture)
if user_picture
user_picture.format("png32")
user_picture.alpha('set')
user_picture.background('none')
user_picture.combine_options do |c|
c.filter "Lanczos" # Use a high-quality filter
c.resize "48x48"
end
end
# Measure username text size
username_metrics = measure_text(user.username, username_font_size, font: username_font_path)
right_padding = PADDING
total_user_width = 48 + 8 + username_metrics[:width]
user_x = image.width - right_padding - total_user_width
# Center user picture vertically relative to party text line
user_pic_y = (party_text_center_y - (48 / 2.0)).round
image = image.composite(user_picture) do |c|
c.compose "Over"
c.geometry "+#{user_x}+#{user_pic_y}"
end if user_picture
# Adjust text y-coordinate to better align vertically with the picture
# You may need to tweak the offset value based on visual inspection.
vertical_offset = 6 # Adjust this value as needed
user_text_y = (party_text_center_y + (username_metrics[:height] / 2.0) - vertical_offset).round
image.combine_options do |c|
c.font username_font_path
c.fill font_color
c.pointsize username_font_size
text_x = user_x + 48 + 12
c.draw "text #{text_x},#{user_text_y} '#{user.username}'"
end
image
end
def measure_text(text, font_size, font: 'Arial')
# Create a temporary file for the text measurement
temp_file = Tempfile.new(['text_measure', '.png'])
begin
# Use ImageMagick command to create an image with the text
command = [
'magick',
'-background', 'transparent',
'-fill', 'black',
'-font', font,
'-pointsize', font_size.to_s,
"label:#{text}",
temp_file.path
]
# Execute the command
system(*command)
# Use MiniMagick to read the image and get dimensions
image = MiniMagick::Image.open(temp_file.path)
{
height: image.height,
width: image.width
}
ensure
# Close and unlink the temporary file
temp_file.close
temp_file.unlink
end
rescue => e
Rails.logger.error "Text measurement error: #{e.message}"
# Fallback dimensions
{ height: 50, width: 200 }
end
end
end

View file

@ -0,0 +1,369 @@
# app/services/preview/coordinator.rb
module PreviewService
class Coordinator
PREVIEW_FOLDER = 'previews'
PREVIEW_WIDTH = 1200
PREVIEW_HEIGHT = 630
PREVIEW_EXPIRY = 30.days
GENERATION_TIMEOUT = 5.minutes
LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews')
# Initialize the party preview service
#
# @param party [Party] The party to generate a preview for
def initialize(party)
@party = party
@image_fetcher = ImageFetcherService.new(AwsService.new)
@grid_service = Grid.new
@canvas_service = Canvas.new(@image_fetcher)
setup_storage
end
# Retrieves the URL for the party's preview image
#
# @return [String] A URL pointing to the party's preview image
def preview_url
if preview_exists?
Rails.env.production? ? generate_s3_url : local_preview_url
else
schedule_generation unless generation_in_progress?
default_preview_url
end
end
# Generates a preview image for the party
#
# @return [Boolean] True if preview generation was successful, false otherwise
def generate_preview
return false unless should_generate?
begin
Rails.logger.info("Starting preview generation for party #{@party.id}")
set_generation_in_progress
# Generate the preview image
image = create_preview_image
save_preview(image)
# Update party state
@party.update!(
preview_state: :generated,
preview_generated_at: Time.current
)
true
rescue => e
handle_preview_generation_error(e)
false
ensure
@image_fetcher.cleanup
clear_generation_in_progress
end
end
# Forces regeneration of the party's preview image
#
# @return [Boolean] Result of the preview generation attempt
def force_regenerate
delete_preview if preview_exists?
generate_preview
end
# Deletes the existing preview image for the party
def delete_preview
if Rails.env.production?
delete_s3_preview
else
delete_local_previews
end
@party.update!(
preview_state: :pending,
preview_generated_at: nil
)
rescue => e
Rails.logger.error("Failed to delete preview for party #{@party.id}: #{e.message}")
end
private
# Sets up the appropriate storage system based on environment
def setup_storage
# Always initialize AWS service for potential image fetching
@aws_service = AwsService.new
# Create local storage paths in development
FileUtils.mkdir_p(LOCAL_STORAGE_PATH) unless Dir.exist?(LOCAL_STORAGE_PATH.to_s)
end
# Creates the preview image for the party
#
# @return [MiniMagick::Image] The generated preview image
def create_preview_image
# Create blank canvas
canvas = @canvas_service.create_blank_canvas
image = MiniMagick::Image.new(canvas.path)
# Fetch job icon
job_icon = nil
if @party.job.present?
job_icon = @image_fetcher.fetch_job_icon(@party.job.granblue_id)
end
# Add party name with job icon
text_result = @canvas_service.add_text(image, @party.name, job_icon: job_icon, user: @party.user)
image = text_result[:image]
# Calculate grid layout
grid_layout = @grid_service.calculate_layout(
canvas_height: Canvas::PREVIEW_HEIGHT,
title_bottom_y: text_result[:text_bottom_y]
)
# Organize and draw weapons
image = organize_and_draw_weapons(image, grid_layout)
image
end
# Adds the job icon to the preview image
#
# @param image [MiniMagick::Image] The base image
# @param job_icon [MiniMagick::Image] The job icon to add
# @return [MiniMagick::Image] The updated image
def add_job_icon(image, job_icon)
job_icon.resize '200x200'
image.composite(job_icon) do |comp|
comp.compose "Over"
comp.geometry "+40+120"
end
end
# Organizes and draws weapons on the preview image
#
# @param image [MiniMagick::Image] The base image
# @return [MiniMagick::Image] The updated image with weapons
def organize_and_draw_weapons(image, grid_layout)
mainhand_weapon = @party.weapons.find(&:mainhand)
grid_weapons = @party.weapons.reject(&:mainhand)
# Draw mainhand weapon
if mainhand_weapon
weapon_image = @image_fetcher.fetch_weapon_image(mainhand_weapon.weapon, mainhand: true)
image = @grid_service.draw_grid_item(image, weapon_image, 'mainhand', 0, grid_layout) if weapon_image
end
# Draw grid weapons
grid_weapons.each_with_index do |weapon, idx|
weapon_image = @image_fetcher.fetch_weapon_image(weapon.weapon)
image = @grid_service.draw_grid_item(image, weapon_image, 'weapon', idx, grid_layout) if weapon_image
end
image
end
# Draws the mainhand weapon on the preview image
#
# @param image [MiniMagick::Image] The base image
# @param weapon_image [MiniMagick::Image] The weapon image to add
# @return [MiniMagick::Image] The updated image
def draw_mainhand_weapon(image, weapon_image)
target_size = Grid::GRID_CELL_SIZE * 1.5
weapon_image.resize "#{target_size}x#{target_size}"
image.composite(weapon_image) do |c|
c.compose "Over"
c.gravity "northwest"
c.geometry "+150+150"
end
end
# Saves the preview image to the appropriate storage system
#
# @param image [MiniMagick::Image] The image to save
def save_preview(image)
if Rails.env.production?
upload_to_s3(image)
else
save_to_local_storage(image)
end
end
# Uploads the preview image to S3
#
# @param image [MiniMagick::Image] The image to upload
def upload_to_s3(image)
temp_file = Tempfile.new(['preview', '.png'])
begin
image.write(temp_file.path)
File.open(temp_file.path, 'rb') do |file|
@aws_service.s3_client.put_object(
bucket: S3_BUCKET,
key: preview_key,
body: file,
content_type: 'image/png',
acl: 'private'
)
end
ensure
temp_file.close
temp_file.unlink
end
end
# Saves the preview image to local storage
#
# @param image [MiniMagick::Image] The image to save
def save_to_local_storage(image)
# Remove any existing previews for this party
Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file|
File.delete(file)
end
# Save new version
image.write(local_preview_path)
end
# Generates a timestamped filename for the preview image
#
# @return [String] Filename in format "shortcode_YYYYMMDDHHMMSS.png"
def preview_filename
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
"#{@party.shortcode}_#{timestamp}.png"
end
# Returns the full path for storing preview images locally
#
# @return [Pathname] Full path where the preview image should be stored
def local_preview_path
LOCAL_STORAGE_PATH.join(preview_filename)
end
# Returns the URL for accessing locally stored preview images
#
# @return [String] URL path to access the preview image in development
def local_preview_url
latest_preview = Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s)
.max_by { |f| File.mtime(f) }
return default_preview_url unless latest_preview
"/party-previews/#{File.basename(latest_preview)}"
end
# Generates the S3 key for the party's preview image
#
# @return [String] The S3 object key for the preview image
def preview_key
"#{PREVIEW_FOLDER}/#{@party.shortcode}.png"
end
# Checks if a preview image exists for the party
#
# @return [Boolean] True if a preview exists, false otherwise
def preview_exists?
return false unless @party.preview_state == 'generated'
if Rails.env.production?
@aws_service.s3_client.head_object(bucket: S3_BUCKET, key: preview_key)
true
else
!Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).empty?
end
rescue Aws::S3::Errors::NotFound
false
end
# Generates a pre-signed S3 URL for the preview image
#
# @return [String] A pre-signed URL to access the preview image
def generate_s3_url
signer = Aws::S3::Presigner.new(client: @aws_service.s3_client)
signer.presigned_url(
:get_object,
bucket: S3_BUCKET,
key: preview_key,
expires_in: 1.hour
)
end
# Determines if a new preview should be generated
#
# @return [Boolean] True if a new preview should be generated, false otherwise
def should_generate?
return false if generation_in_progress?
return true if @party.preview_state.in?(['pending', 'failed'])
if @party.preview_state == 'generated'
return @party.preview_generated_at < PREVIEW_EXPIRY.ago
end
false
end
# Checks if a preview generation is currently in progress
#
# @return [Boolean] True if a preview is being generated, false otherwise
def generation_in_progress?
Rails.cache.exist?("party_preview_generating_#{@party.id}")
end
# Marks the preview generation as in progress
def set_generation_in_progress
Rails.cache.write(
"party_preview_generating_#{@party.id}",
true,
expires_in: GENERATION_TIMEOUT
)
end
# Clears the in-progress flag for preview generation
def clear_generation_in_progress
Rails.cache.delete("party_preview_generating_#{@party.id}")
end
# Schedules a background job to generate the preview
def schedule_generation
GeneratePartyPreviewJob
.set(wait: 30.seconds)
.perform_later(@party.id)
@party.update!(preview_state: :queued)
end
# Provides a default preview URL based on party attributes
#
# @return [String] A URL to a default preview image
def default_preview_url
if @party.element.present?
"/default-previews/#{@party.element}.png"
else
"/default-previews/default.png"
end
end
# Deletes the preview from S3
def delete_s3_preview
@aws_service.s3_client.delete_object(
bucket: S3_BUCKET,
key: preview_key
)
end
# Deletes local preview files
def delete_local_previews
Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file|
File.delete(file)
end
end
# Handles errors during preview generation
#
# @param error [Exception] The error that occurred
def handle_preview_generation_error(error)
Rails.logger.error("Preview generation failed for party #{@party.id}")
Rails.logger.error("Error: #{error.class} - #{error.message}")
Rails.logger.error("Backtrace:\n#{error.backtrace.join("\n")}")
@party.update!(preview_state: :failed)
end
end
end

View file

@ -0,0 +1,107 @@
# app/services/grid.rb
module PreviewService
class Grid
GRID_GAP = 8
GRID_COLUMNS = 4
GRID_ROWS = 3
GRID_SCALE = 0.75 # Scale for grid images
# Natural dimensions
MAINHAND_NATURAL_WIDTH = 200
MAINHAND_NATURAL_HEIGHT = 420
GRID_NATURAL_WIDTH = 280
GRID_NATURAL_HEIGHT = 160
# Scaled grid dimensions
CELL_WIDTH = (GRID_NATURAL_WIDTH * GRID_SCALE).floor
CELL_HEIGHT = (GRID_NATURAL_HEIGHT * GRID_SCALE).floor
def calculate_layout(canvas_height:, title_bottom_y:, padding: 24)
# Use scaled dimensions for grid images
cell_width = CELL_WIDTH
cell_height = CELL_HEIGHT
grid_columns = GRID_COLUMNS - 1 # 3 columns for grid items
grid_total_width = cell_width * grid_columns + GRID_GAP * (grid_columns - 1)
grid_total_height = cell_height * GRID_ROWS + GRID_GAP * (GRID_ROWS - 1)
# Determine the scale factor for the mainhand to match grid height
mainhand_scale = grid_total_height.to_f / MAINHAND_NATURAL_HEIGHT
scaled_mainhand_width = (MAINHAND_NATURAL_WIDTH * mainhand_scale).floor
scaled_mainhand_height = (MAINHAND_NATURAL_HEIGHT * mainhand_scale).floor
total_width = scaled_mainhand_width + GRID_GAP + grid_total_width
# Center the grid absolutely in the canvas
grid_start_y = (canvas_height - grid_total_height) / 2
{
cell_width: cell_width,
cell_height: cell_height,
grid_total_width: grid_total_width,
grid_total_height: grid_total_height,
total_width: total_width,
grid_columns: grid_columns,
grid_start_y: grid_start_y,
mainhand_width: scaled_mainhand_width,
mainhand_height: scaled_mainhand_height
}
end
def grid_position(type, idx, layout)
case type
when 'mainhand'
{
x: (Canvas::PREVIEW_WIDTH - layout[:total_width]) / 2,
y: layout[:grid_start_y]
# No explicit width/height here since resizing is handled in draw_grid_item
}
when 'weapon'
row = idx / layout[:grid_columns]
col = idx % layout[:grid_columns]
{
x: (Canvas::PREVIEW_WIDTH - layout[:total_width]) / 2 + layout[:mainhand_width] + GRID_GAP + col * (layout[:cell_width] + GRID_GAP),
y: layout[:grid_start_y] + row * (layout[:cell_height] + GRID_GAP),
width: layout[:cell_width],
height: layout[:cell_height]
}
end
end
def draw_grid_item(image, item_image, type, idx, layout)
coords = grid_position(type, idx, layout)
if type == 'mainhand'
# Resize mainhand using scaled dimensions from layout
item_image.resize "#{layout[:mainhand_width]}x#{layout[:mainhand_height]}"
item_image = round_corners(item_image, 4)
else
# Resize grid items to fixed, scaled dimensions and round corners
item_image.resize "#{coords[:width]}x#{coords[:height]}^"
item_image = round_corners(item_image, 4)
end
image.composite(item_image) do |c|
c.compose "Over"
c.geometry "+#{coords[:x]}+#{coords[:y]}"
end
end
def round_corners(image, radius = 8)
# Create a round-corner mask for the image
mask = MiniMagick::Image.open(image.path)
mask.format "png"
mask.combine_options do |m|
m.alpha "transparent"
m.background "none"
m.fill "white"
m.draw "roundRectangle 0,0,#{mask.width},#{mask.height},#{radius},#{radius}"
end
image.composite(mask) do |c|
c.compose "DstIn"
end
end
end
end

View file

@ -0,0 +1,69 @@
# app/services/image_fetcher_service.rb
module PreviewService
class ImageFetcherService
def initialize(aws_service)
@aws_service = aws_service
@tempfiles = []
end
def fetch_s3_image(key, folder = nil)
full_key = folder ? "#{folder}/#{key}" : key
temp_file = create_temp_file
download_from_s3(full_key, temp_file)
create_mini_magick_image(temp_file)
rescue => e
handle_fetch_error(e, full_key)
end
def fetch_job_icon(job_name)
fetch_s3_image("#{job_name.downcase}.png", 'job-icons')
end
def fetch_weapon_image(weapon, mainhand: false)
folder = mainhand ? 'weapon-main' : 'weapon-grid'
fetch_s3_image("#{weapon.granblue_id}.jpg", folder)
end
def fetch_user_picture(picture_identifier)
# Assuming user pictures are stored as PNG in a folder called 'user-pictures'
fetch_s3_image("#{picture_identifier}.png", 'profile')
end
def cleanup
@tempfiles.each do |tempfile|
tempfile.close
tempfile.unlink
end
@tempfiles.clear
end
private
def create_temp_file
temp_file = Tempfile.new(['image', '.jpg'])
temp_file.binmode
@tempfiles << temp_file
temp_file
end
def download_from_s3(key, temp_file)
response = @aws_service.s3_client.get_object(
bucket: @aws_service.bucket,
key: key
)
temp_file.write(response.body.read)
temp_file.rewind
end
def create_mini_magick_image(temp_file)
MiniMagick::Image.new(temp_file.path)
end
def handle_fetch_error(error, key)
Rails.logger.error "Error fetching image #{key}: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
nil
end
end
end

View file

@ -0,0 +1,54 @@
# app/services/preview_generation_monitor.rb
module PreviewService
class PreviewGenerationMonitor
class << self
def check_stalled_jobs
Party.where(preview_state: :queued)
.where('updated_at < ?', 10.minutes.ago)
.find_each do |party|
Rails.logger.warn("Found stalled preview generation for party #{party.id}")
# If no job is actually queued, reset the state
unless job_exists?(party)
party.update!(preview_state: :pending)
Rails.logger.info("Reset stalled party #{party.id} to pending state")
end
end
end
def retry_failed
Party.where(preview_state: :failed)
.where('updated_at < ?', 1.hour.ago)
.find_each do |party|
Rails.logger.info("Retrying failed preview generation for party #{party.id}")
GeneratePartyPreviewJob.perform_later(party.id)
end
end
def cleanup_old_previews
Party.where(preview_state: :generated)
.where('preview_generated_at < ?', 30.days.ago)
.find_each do |party|
PreviewService::Coordinator.new(party).delete_preview
end
end
private
def job_exists?(party)
# Implementation depends on your job backend
# For Sidekiq:
queue = Sidekiq::Queue.new('previews')
scheduled = Sidekiq::ScheduledSet.new
retrying = Sidekiq::RetrySet.new
[queue, scheduled, retrying].any? do |set|
set.any? do |job|
job.args.first == party.id &&
job.klass == 'GeneratePartyPreviewJob'
end
end
end
end
end
end

View file

@ -0,0 +1,18 @@
require 'rufus-scheduler'
# Don't schedule jobs in test environment or when running rake tasks
unless defined?(Rails::Console) || Rails.env.test? || File.split($0).last == 'rake'
scheduler = Rufus::Scheduler.new
scheduler.every '5m' do
PreviewGenerationMonitor.check_stalled_jobs
end
scheduler.every '1h' do
PreviewGenerationMonitor.retry_failed
end
scheduler.every '1d' do
PreviewGenerationMonitor.cleanup_old_previews
end
end

View file

@ -24,6 +24,8 @@ Rails.application.routes.draw do
get 'parties/favorites', to: 'parties#favorites' get 'parties/favorites', to: 'parties#favorites'
get 'parties/:id', to: 'parties#show' get 'parties/:id', to: 'parties#show'
get 'parties/:id/preview', to: 'parties#preview'
post 'parties/:id/regenerate_preview', to: 'parties#regenerate_preview'
post 'parties/:id/remix', to: 'parties#remix' post 'parties/:id/remix', to: 'parties#remix'
put 'parties/:id/jobs', to: 'jobs#update_job' put 'parties/:id/jobs', to: 'jobs#update_job'
@ -72,4 +74,20 @@ Rails.application.routes.draw do
delete 'favorites', to: 'favorites#destroy' delete 'favorites', to: 'favorites#destroy'
end end
end end
if Rails.env.development?
get '/party-previews/*filename', to: proc { |env|
filename = env['action_dispatch.request.path_parameters'][:filename]
path = Rails.root.join('storage', 'party-previews', filename)
if File.exist?(path)
[200, {
'Content-Type' => 'image/png',
'Cache-Control' => 'no-cache' # Prevent caching during development
}, [File.read(path)]]
else
[404, { 'Content-Type' => 'text/plain' }, ['Preview not found']]
end
}
end
end end

View file

@ -0,0 +1,9 @@
class AddPreviewColumnsToParties < ActiveRecord::Migration[8.0]
def change
add_column :parties, :preview_state, :integer, default: 0, null: false
add_column :parties, :preview_generated_at, :datetime
add_index :parties, :preview_state
add_index :parties, :preview_generated_at
end
end

View file

@ -10,12 +10,12 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_01_15_100356) do ActiveRecord::Schema[8.0].define(version: 2025_01_18_135254) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_trgm" enable_extension "pg_trgm"
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql"
enable_extension "uuid-ossp" enable_extension "uuid-ossp"
create_table "app_updates", primary_key: "updated_at", id: :datetime, force: :cascade do |t| create_table "app_updates", primary_key: "updated_at", id: :datetime, force: :cascade do |t|
@ -300,11 +300,15 @@ ActiveRecord::Schema[7.0].define(version: 2025_01_15_100356) do
t.boolean "auto_summon", default: false t.boolean "auto_summon", default: false
t.boolean "remix", default: false, null: false t.boolean "remix", default: false, null: false
t.integer "visibility", default: 1, null: false t.integer "visibility", default: 1, null: false
t.integer "preview_state", default: 0, null: false
t.datetime "preview_generated_at"
t.index ["accessory_id"], name: "index_parties_on_accessory_id" t.index ["accessory_id"], name: "index_parties_on_accessory_id"
t.index ["guidebook1_id"], name: "index_parties_on_guidebook1_id" t.index ["guidebook1_id"], name: "index_parties_on_guidebook1_id"
t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id" t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id"
t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_id" t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_id"
t.index ["job_id"], name: "index_parties_on_job_id" t.index ["job_id"], name: "index_parties_on_job_id"
t.index ["preview_generated_at"], name: "index_parties_on_preview_generated_at"
t.index ["preview_state"], name: "index_parties_on_preview_state"
t.index ["skill0_id"], name: "index_parties_on_skill0_id" t.index ["skill0_id"], name: "index_parties_on_skill0_id"
t.index ["skill1_id"], name: "index_parties_on_skill1_id" t.index ["skill1_id"], name: "index_parties_on_skill1_id"
t.index ["skill2_id"], name: "index_parties_on_skill2_id" t.index ["skill2_id"], name: "index_parties_on_skill2_id"

19
sig/aws_service.rbs Normal file
View file

@ -0,0 +1,19 @@
class AwsService
class ConfigurationError < StandardError
end
attr_reader bucket: String
attr_reader s3_client: Aws::S3::Client
def initialize: () -> void
def upload_stream: (IO io, String key) -> Aws::S3::Types::PutObjectOutput
def file_exists?: (String key) -> bool
private
def credentials: () -> Hash[Symbol, String]
def validate_credentials!: () -> void
end

View file

@ -0,0 +1,23 @@
module PreviewService
class Canvas
PREVIEW_WIDTH: Integer
PREVIEW_HEIGHT: Integer
DEFAULT_BACKGROUND_COLOR: String
def create_blank_canvas: (
?width: Integer,
?height: Integer,
?color: String
) -> Tempfile
def add_text: (
MiniMagick::Image image,
String text,
?x: Integer,
?y: Integer,
?size: String,
?color: String,
?font: String
) -> MiniMagick::Image
end
end

View file

@ -0,0 +1,74 @@
module PreviewService
class Coordinator
PREVIEW_FOLDER: String
PREVIEW_WIDTH: Integer
PREVIEW_HEIGHT: Integer
PREVIEW_EXPIRY: ActiveSupport::Duration
GENERATION_TIMEOUT: ActiveSupport::Duration
LOCAL_STORAGE_PATH: Pathname
@party: Party
@image_fetcher: ImageFetcherService
@grid_service: Grid
@canvas_service: Canvas
@aws_service: AwsService
def initialize: (Party party) -> void
def preview_url: -> String
def generate_preview: -> bool
def force_regenerate: -> bool
def delete_preview: -> void
private
def create_preview_image: -> MiniMagick::Image
def add_job_icon: (MiniMagick::Image image, MiniMagick::Image job_icon) -> MiniMagick::Image
def organize_and_draw_weapons: (MiniMagick::Image image) -> MiniMagick::Image
def draw_mainhand_weapon: (MiniMagick::Image image, MiniMagick::Image weapon_image) -> MiniMagick::Image
def save_preview: (MiniMagick::Image image) -> void
def setup_storage: -> void
def upload_to_s3: (MiniMagick::Image image) -> void
def save_to_local_storage: (MiniMagick::Image image) -> void
def preview_filename: -> String
def local_preview_path: -> Pathname
def local_preview_url: -> String
def preview_key: -> String
def preview_exists?: -> bool
def generate_s3_url: -> String
def should_generate?: -> bool
def generation_in_progress?: -> bool
def set_generation_in_progress: -> void
def clear_generation_in_progress: -> void
def schedule_generation: -> void
def default_preview_url: -> String
def delete_s3_preview: -> void
def delete_local_previews: -> void
def handle_preview_generation_error: (Exception error) -> void
end
end

View file

@ -0,0 +1,18 @@
module PreviewService
class Grid
GRID_MARGIN: Integer
GRID_CELL_SIZE: Integer
GRID_START_X: Integer
GRID_START_Y: Integer
def grid_position: (String type, Integer idx) -> { x: Integer, y: Integer }
def draw_grid_item: (
MiniMagick::Image image,
MiniMagick::Image item_image,
String type,
Integer idx,
?resize_to: Integer
) -> MiniMagick::Image
end
end

View file

@ -0,0 +1,26 @@
module PreviewService
class ImageFetcherService
@aws_service: AwsService
@tempfiles: Array[Tempfile]
def initialize: (AwsService aws_service) -> void
def fetch_s3_image: (String key, ?String folder) -> MiniMagick::Image?
def fetch_job_icon: (String job_name) -> MiniMagick::Image?
def fetch_weapon_image: (Weapon weapon, ?mainhand: bool) -> MiniMagick::Image?
def cleanup: -> void
private
def create_temp_file: -> Tempfile
def download_from_s3: (String key, Tempfile temp_file) -> void
def create_mini_magick_image: (Tempfile temp_file) -> MiniMagick::Image
def handle_fetch_error: (Exception error, String key) -> nil
end
end