Compare commits
7 commits
main
...
jedmund/em
| Author | SHA1 | Date | |
|---|---|---|---|
| 00890eda10 | |||
| dc55e7cdee | |||
| 0257469582 | |||
| 8d047be149 | |||
| a55ea1e50f | |||
| 74b59efd5f | |||
| c0df0fbd13 |
24 changed files with 1139 additions and 2 deletions
6
Gemfile
6
Gemfile
|
|
@ -32,6 +32,9 @@ gem 'responders'
|
|||
# Parse emoji to strings
|
||||
gem 'gemoji-parser'
|
||||
|
||||
# Mini replacement for RMagick
|
||||
gem 'mini_magick'
|
||||
|
||||
# An awesome replacement for acts_as_nested_set and better_nested_set.
|
||||
gem 'awesome_nested_set'
|
||||
|
||||
|
|
@ -44,6 +47,9 @@ gem 'email_validator'
|
|||
# pg_search builds ActiveRecord named scopes that take advantage of PostgreSQL’s full text search
|
||||
gem 'pg_search'
|
||||
|
||||
# scheduler for Ruby (at, in, cron and every jobs)
|
||||
gem 'rufus-scheduler'
|
||||
|
||||
# Pagination library
|
||||
gem 'will_paginate', '~> 3.3'
|
||||
|
||||
|
|
|
|||
11
Gemfile.lock
11
Gemfile.lock
|
|
@ -139,6 +139,8 @@ GEM
|
|||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
factory_bot (6.5.0)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.4.4)
|
||||
|
|
@ -156,6 +158,9 @@ GEM
|
|||
ffi (1.17.1-x86_64-linux-musl)
|
||||
figaro (1.2.0)
|
||||
thor (>= 0.14.0, < 2)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
gemoji (4.1.0)
|
||||
gemoji-parser (1.3.1)
|
||||
gemoji (>= 2.1.0)
|
||||
|
|
@ -193,6 +198,7 @@ GEM
|
|||
net-smtp
|
||||
marcel (1.0.4)
|
||||
method_source (1.1.0)
|
||||
mini_magick (5.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.4)
|
||||
msgpack (1.7.5)
|
||||
|
|
@ -244,6 +250,7 @@ GEM
|
|||
stringio
|
||||
puma (6.5.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.8)
|
||||
rack-cors (2.0.2)
|
||||
|
|
@ -337,6 +344,8 @@ GEM
|
|||
rubocop-ast (1.37.0)
|
||||
parser (>= 3.3.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
sdoc (2.6.1)
|
||||
rdoc (>= 5.0)
|
||||
securerandom (0.4.1)
|
||||
|
|
@ -427,6 +436,7 @@ DEPENDENCIES
|
|||
gemoji-parser
|
||||
httparty
|
||||
listen
|
||||
mini_magick
|
||||
oj
|
||||
pg
|
||||
pg_search
|
||||
|
|
@ -438,6 +448,7 @@ DEPENDENCIES
|
|||
rspec-rails
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rufus-scheduler
|
||||
sdoc
|
||||
shoulda-matchers
|
||||
simplecov
|
||||
|
|
|
|||
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.
|
|
@ -116,6 +116,30 @@ module Api
|
|||
render_party_json(@parties, count, total_pages)
|
||||
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
|
||||
|
||||
def authorize
|
||||
|
|
|
|||
4
app/jobs/application_job.rb
Normal file
4
app/jobs/application_job.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
end
|
||||
11
app/jobs/cleanup_party_previews_job.rb
Normal file
11
app/jobs/cleanup_party_previews_job.rb
Normal 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
|
||||
75
app/jobs/generate_party_preview_job.rb
Normal file
75
app/jobs/generate_party_preview_job.rb
Normal 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
|
||||
|
|
@ -105,6 +105,15 @@ class Party < ApplicationRecord
|
|||
|
||||
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)
|
||||
user.favorite_parties.include? self if user
|
||||
end
|
||||
|
|
@ -177,4 +186,18 @@ class Party < ApplicationRecord
|
|||
|
||||
errors.add(:guidebooks, 'must be unique')
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
require 'aws-sdk-s3'
|
||||
|
||||
class AwsService
|
||||
attr_reader :s3_client, :bucket
|
||||
|
||||
class ConfigurationError < StandardError; end
|
||||
|
||||
def initialize
|
||||
|
|
|
|||
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
|
||||
18
config/initializers/scheduler.rb
Normal file
18
config/initializers/scheduler.rb
Normal 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
|
||||
|
|
@ -24,6 +24,8 @@ Rails.application.routes.draw do
|
|||
|
||||
get 'parties/favorites', to: 'parties#favorites'
|
||||
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'
|
||||
|
||||
put 'parties/:id/jobs', to: 'jobs#update_job'
|
||||
|
|
@ -72,4 +74,20 @@ Rails.application.routes.draw do
|
|||
delete 'favorites', to: 'favorites#destroy'
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -10,12 +10,12 @@
|
|||
#
|
||||
# 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
|
||||
enable_extension "btree_gin"
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pg_trgm"
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
enable_extension "uuid-ossp"
|
||||
|
||||
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 "remix", default: false, 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 ["guidebook1_id"], name: "index_parties_on_guidebook1_id"
|
||||
t.index ["guidebook2_id"], name: "index_parties_on_guidebook2_id"
|
||||
t.index ["guidebook3_id"], name: "index_parties_on_guidebook3_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 ["skill1_id"], name: "index_parties_on_skill1_id"
|
||||
t.index ["skill2_id"], name: "index_parties_on_skill2_id"
|
||||
|
|
|
|||
19
sig/aws_service.rbs
Normal file
19
sig/aws_service.rbs
Normal 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
|
||||
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