hensei-api/app/services/preview_service/coordinator.rb
Justin Edmund d6300f7aeb
Add first round of tests (#178)
* Install Rspec

* Create .aidigestignore

* Update rails_helper

- Added sections and comments
- Add support for loading via canonical.rb
- Add FactoryBot syntax methods
- Disable SQL logging in test environment

* Move gems around

* Add canonical.rb and test env CSVs

We load these CSVs via canonical.rb when we run tests as a data source for canonical objects.

* Remove RBS for now

This is too much and we need to find the right solution

* Refactor GridSummonsController and add tests

* Create GridSummon factory

* Refactor GridSummon and add documentation and tests

* Create have_error_on.rb

* Update .aidigestignore

* Fix warnings

* Add GridWeapons and Parties factories

* Refactor GridWeapon and add documentation and tests

* Create .rubocop.yml

* Create no_weapon_provided_error.rb

* Refactor GridWeaponsController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Refactor GridSummonsController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Enable shoulda/matchers

* Update User factory

* Update party.rb

We moved updating the party's element and extra flag to inside the party. We use an after_commit hook to minimize the amount of queries we're running to do this.

* Update party.rb

We change setting the edit key to use the conditional assignment operator so that it doesn't get overridden when we're running tests. This shouldn't have an effect in production.

* Update api_controller.rb

Change render_unprocessable_entity_response to render the errors hash instead of the exception so that we get more helpful errors.

* Add new errors

Added NoCharacterProvidedError and NoSummonProvidedError

* Add tests and docs to GridCharacter

We added a factory, spec and documentation to the GridCharacter model

* Ensure numericality

* Move enums into GranblueEnums

We don't use these yet, but it gives us a structured place to pull them from.

* Refactor GridCharactersController

- Refactors controller
- Adds YARD documentation
- Adds Rspec tests

* Add debug hook and other small changes

* Update grid_characters_controller.rb

Removes logs

* Update .gitignore

* Update .aidigestignore

* Refactored PartiesController

- Split PartiesController into three concerns
- Implemented testing for PartiesController and two concerns
- Implemented fixes across other files to ensure PartiesController tests pass
- Added Favorites factory

* Implement SimpleCov

* Refactor Party model

- Refactors Party model
- Adds tests
- Adds documentation

* Update granblue_enums.rb

Remove included block
2025-02-12 02:42:30 -08:00

532 lines
17 KiB
Ruby

# 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')
PREVIEW_DEBOUNCE_PERIOD = 5.minutes
# Public Interface - Core Operations
# 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}")
Rails.logger.info("🖼️ Updating party state to in_progress")
@party.update!(preview_state: :in_progress)
set_generation_in_progress
Rails.logger.info("🖼️ Checking ImageMagick installation...")
begin
version = `convert -version`
Rails.logger.info("🖼️ ImageMagick version: #{version}")
rescue => e
Rails.logger.error("🖼️ Failed to get ImageMagick version: #{e.message}")
end
Rails.logger.info("🖼️ Creating preview image...")
begin
image = create_preview_image
Rails.logger.info("🖼️ Preview image created successfully")
rescue => e
Rails.logger.error("🖼️ Failed to create preview image: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
raise e
end
Rails.logger.info("🖼️ Saving preview...")
begin
save_preview(image)
Rails.logger.info("🖼️ Preview saved successfully")
rescue => e
Rails.logger.error("🖼️ Failed to save preview: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
raise e
end
Rails.logger.info("🖼️ Updating party state...")
@party.update!(
preview_state: :generated,
preview_generated_at: Time.current
)
Rails.logger.info("🖼️ Party state updated successfully")
true
rescue => e
Rails.logger.error("🖼️ Preview generation failed: #{e.class} - #{e.message}")
Rails.logger.error("🖼️ Stack trace:")
Rails.logger.error(e.backtrace.join("\n"))
handle_preview_generation_error(e)
false
ensure
Rails.logger.info("🖼️ Cleaning up resources...")
@image_fetcher.cleanup
clear_generation_in_progress
Rails.logger.info("🖼️ Cleanup completed")
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
#
# @return [void]
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
# State Management - Public
# Determines if a new preview should be generated
#
# @return [Boolean] True if a new preview should be generated, false otherwise
def should_generate?
Rails.logger.info("🖼️ Checking should_generate? conditions")
unless @party.ready_for_preview?
Rails.logger.info("🖼️ Party not ready for preview (insufficient content)")
return false
end
if generation_in_progress?
Rails.logger.info("🖼️ Generation already in progress, returning false")
return false
end
Rails.logger.info("🖼️ Preview state: #{@party.preview_state}")
case @party.preview_state
when 'pending', 'queued'
Rails.logger.info("🖼️ State is #{@party.preview_state}, will generate")
true
when 'in_progress'
Rails.logger.info("🖼️ State is in_progress, skipping generation")
false
when 'failed'
should_retry = @party.preview_generated_at.nil? ||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
Rails.logger.info("🖼️ Failed state, should retry: #{should_retry}")
should_retry
when 'generated'
expired = @party.preview_expired?
changed = @party.preview_content_changed?
debounced = @party.preview_generated_at.nil? ||
@party.preview_generated_at < PREVIEW_DEBOUNCE_PERIOD.ago
should_regenerate = expired || (changed && debounced)
Rails.logger.info("🖼️ Generated state check - expired: #{expired}, content changed: #{changed}, debounced: #{debounced}")
Rails.logger.info("🖼️ Should regenerate: #{should_regenerate}")
should_regenerate
else
Rails.logger.info("🖼️ Unknown state, will generate")
true
end
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?
in_progress = Rails.cache.exist?("party_preview_generating_#{@party.id}")
Rails.logger.info("Cache key check for generation_in_progress: #{in_progress}")
in_progress
end
# Retrieves the S3 object for the party's preview image
#
# @return [Aws::S3::Types::GetObjectOutput] S3 object containing the preview image
# @raise [Aws::S3::Errors::NoSuchKey] If the preview image doesn't exist in S3
# @raise [Aws::S3::Errors::NoSuchBucket] If the configured bucket doesn't exist
def get_s3_object
@aws_service.s3_client.get_object(
bucket: @aws_service.bucket,
key: preview_key
)
end
# Schedules a background job to generate the preview
#
# @return [void]
def schedule_generation
GeneratePartyPreviewJob
.set(wait: 30.seconds)
.perform_later(@party.id)
@party.update!(preview_state: :queued)
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
# Creates the preview image for the party
#
# @return [MiniMagick::Image] The generated preview image
def create_preview_image
Rails.logger.info("Creating blank canvas...")
begin
canvas = @canvas_service.create_blank_canvas
Rails.logger.info("Canvas created at: #{canvas.path}")
image = MiniMagick::Image.new(canvas.path)
Rails.logger.info("MiniMagick image object created")
rescue => e
Rails.logger.error("Failed to create canvas: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
raise e
end
# Add more detailed logging for job icon handling
Rails.logger.info("Processing job icon...")
job_icon = nil
if @party.job.present?
Rails.logger.info("Job present: #{@party.job.inspect}")
Rails.logger.info("Fetching job icon for job ID: #{@party.job.granblue_id}")
begin
job_icon = @image_fetcher.fetch_job_icon(@party.job.granblue_id)
Rails.logger.info("Job icon fetched successfully") if job_icon
rescue => e
Rails.logger.error("Failed to fetch job icon: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
# Don't raise this error, just log it and continue without the job icon
end
end
begin
Rails.logger.info("Adding party name and job icon...")
text_result = @canvas_service.add_text(image, @party.name, job_icon: job_icon, user: @party.user)
image = text_result[:image]
Rails.logger.info("Text and icon added successfully")
rescue => e
Rails.logger.error("Failed to add text/icon: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
raise e
end
begin
Rails.logger.info("Calculating grid layout...")
grid_layout = @grid_service.calculate_layout(
canvas_height: Canvas::PREVIEW_HEIGHT,
title_bottom_y: text_result[:text_bottom_y]
)
Rails.logger.info("Grid layout calculated")
Rails.logger.info("Drawing weapons...")
Rails.logger.info("Weapons count: #{@party.weapons.count}")
image = organize_and_draw_weapons(image, grid_layout)
Rails.logger.info("Weapons drawn successfully")
rescue => e
Rails.logger.error("Failed during weapons drawing: #{e.class} - #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
raise e
end
image
end
private
# Sets up the appropriate storage system based on environment
#
# @return [void]
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
# Image Generation Pipeline
# 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
# @param grid_layout [Hash] The layout configuration for the grid
# @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
# Storage Operations
# Saves the preview image to the appropriate storage system
#
# @param image [MiniMagick::Image] The image to save
# @return [void]
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
# @return [void]
def upload_to_s3(image)
temp_file = Tempfile.new(%w[preview .png])
begin
image.write(temp_file.path)
# Use fixed key without timestamp
key = "#{PREVIEW_FOLDER}/#{@party.shortcode}.png"
File.open(temp_file.path, 'rb') do |file|
@aws_service.s3_client.put_object(
bucket: @aws_service.bucket,
key: key,
body: file,
content_type: 'image/png',
acl: 'private'
)
end
@party.update!(preview_s3_key: key)
ensure
temp_file.close
temp_file.unlink
end
end
# Saves the preview image to local storage
#
# @param image [MiniMagick::Image] The image to save
# @return [void]
def save_to_local_storage(image)
image.write(local_preview_path)
end
# Path & URL Generation
# Generates a filename for the preview image
#
# @return [String] Filename for the preview image
def preview_filename
"#{@party.shortcode}.png"
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
# Preview State Management
# 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.file_exists?(preview_key)
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: @aws_service.bucket,
key: preview_key,
expires_in: 1.hour.to_i
)
end
# Marks the preview generation as in progress
#
# @return [void]
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
#
# @return [void]
def clear_generation_in_progress
Rails.cache.delete("party_preview_generating_#{@party.id}")
end
# Job Scheduling
# URL Generation
# 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
# Cleanup Operations
# Deletes the preview from S3
#
# @return [void]
def delete_s3_preview
@aws_service.s3_client.delete_object(
bucket: @aws_service.bucket,
key: preview_key
)
end
def self.cleanup_stalled_jobs
Party.where(preview_state: :in_progress)
.where('updated_at < ?', 10.minutes.ago)
.update_all(preview_state: :pending)
end
# Deletes local preview files
#
# @return [void]
def delete_local_previews
Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file|
File.delete(file)
end
end
# Error Handling
# Handles errors during preview generation
#
# @param error [Exception] The error that occurred
# @return [void]
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(error.backtrace.join("\n"))
@party.update_columns(
preview_state: 'failed',
preview_generated_at: Time.current
)
end
end
end