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:
parent
dc55e7cdee
commit
00890eda10
11 changed files with 913 additions and 0 deletions
BIN
app/assets/fonts/Gk-Bd.otf
Normal file
BIN
app/assets/fonts/Gk-Bd.otf
Normal file
Binary file not shown.
BIN
app/assets/fonts/Gk-Rg.otf
Normal file
BIN
app/assets/fonts/Gk-Rg.otf
Normal file
Binary file not shown.
173
app/services/preview_service/canvas.rb
Normal file
173
app/services/preview_service/canvas.rb
Normal 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
|
||||
369
app/services/preview_service/coordinator.rb
Normal file
369
app/services/preview_service/coordinator.rb
Normal 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
|
||||
107
app/services/preview_service/grid.rb
Normal file
107
app/services/preview_service/grid.rb
Normal 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
|
||||
69
app/services/preview_service/image_fetcher_service.rb
Normal file
69
app/services/preview_service/image_fetcher_service.rb
Normal 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
|
||||
54
app/services/preview_service/preview_generation_monitor.rb
Normal file
54
app/services/preview_service/preview_generation_monitor.rb
Normal 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
|
||||
23
sig/preview_service/canvas.rbs
Normal file
23
sig/preview_service/canvas.rbs
Normal 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
|
||||
74
sig/preview_service/coordinator.rbs
Normal file
74
sig/preview_service/coordinator.rbs
Normal 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
|
||||
18
sig/preview_service/grid.rbs
Normal file
18
sig/preview_service/grid.rbs
Normal 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
|
||||
26
sig/preview_service/image_fetcher_service.rbs
Normal file
26
sig/preview_service/image_fetcher_service.rbs
Normal 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
|
||||
Loading…
Reference in a new issue