diff --git a/Gemfile b/Gemfile index ed980fb..692fac3 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index c37d19d..a4b50aa 100644 --- a/Gemfile.lock +++ b/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 diff --git a/app/assets/fonts/Gk-Bd.otf b/app/assets/fonts/Gk-Bd.otf new file mode 100644 index 0000000..0300e43 Binary files /dev/null and b/app/assets/fonts/Gk-Bd.otf differ diff --git a/app/assets/fonts/Gk-Rg.otf b/app/assets/fonts/Gk-Rg.otf new file mode 100644 index 0000000..3c5cfb7 Binary files /dev/null and b/app/assets/fonts/Gk-Rg.otf differ diff --git a/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index 35a620d..409abf3 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -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 diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d92ffdd --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ApplicationJob < ActiveJob::Base +end diff --git a/app/jobs/cleanup_party_previews_job.rb b/app/jobs/cleanup_party_previews_job.rb new file mode 100644 index 0000000..05f2d3c --- /dev/null +++ b/app/jobs/cleanup_party_previews_job.rb @@ -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 diff --git a/app/jobs/generate_party_preview_job.rb b/app/jobs/generate_party_preview_job.rb new file mode 100644 index 0000000..2d87f9b --- /dev/null +++ b/app/jobs/generate_party_preview_job.rb @@ -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 diff --git a/app/models/party.rb b/app/models/party.rb index 5d71f4c..f0944e7 100644 --- a/app/models/party.rb +++ b/app/models/party.rb @@ -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 diff --git a/app/services/aws_service.rb b/app/services/aws_service.rb index 332b3ec..3fa5997 100644 --- a/app/services/aws_service.rb +++ b/app/services/aws_service.rb @@ -3,6 +3,8 @@ require 'aws-sdk-s3' class AwsService + attr_reader :s3_client, :bucket + class ConfigurationError < StandardError; end def initialize diff --git a/app/services/preview_service/canvas.rb b/app/services/preview_service/canvas.rb new file mode 100644 index 0000000..95879fb --- /dev/null +++ b/app/services/preview_service/canvas.rb @@ -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 diff --git a/app/services/preview_service/coordinator.rb b/app/services/preview_service/coordinator.rb new file mode 100644 index 0000000..b6fb964 --- /dev/null +++ b/app/services/preview_service/coordinator.rb @@ -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 diff --git a/app/services/preview_service/grid.rb b/app/services/preview_service/grid.rb new file mode 100644 index 0000000..a5ee36f --- /dev/null +++ b/app/services/preview_service/grid.rb @@ -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 diff --git a/app/services/preview_service/image_fetcher_service.rb b/app/services/preview_service/image_fetcher_service.rb new file mode 100644 index 0000000..d1cc9d3 --- /dev/null +++ b/app/services/preview_service/image_fetcher_service.rb @@ -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 diff --git a/app/services/preview_service/preview_generation_monitor.rb b/app/services/preview_service/preview_generation_monitor.rb new file mode 100644 index 0000000..74b7c37 --- /dev/null +++ b/app/services/preview_service/preview_generation_monitor.rb @@ -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 diff --git a/config/initializers/scheduler.rb b/config/initializers/scheduler.rb new file mode 100644 index 0000000..ec3e92a --- /dev/null +++ b/config/initializers/scheduler.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 4a7e1dc..9ec0148 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20250118135254_add_preview_columns_to_parties.rb b/db/migrate/20250118135254_add_preview_columns_to_parties.rb new file mode 100644 index 0000000..0eff4de --- /dev/null +++ b/db/migrate/20250118135254_add_preview_columns_to_parties.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 22ac3b7..ffaeb01 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/sig/aws_service.rbs b/sig/aws_service.rbs new file mode 100644 index 0000000..2deff53 --- /dev/null +++ b/sig/aws_service.rbs @@ -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 diff --git a/sig/preview_service/canvas.rbs b/sig/preview_service/canvas.rbs new file mode 100644 index 0000000..610e066 --- /dev/null +++ b/sig/preview_service/canvas.rbs @@ -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 diff --git a/sig/preview_service/coordinator.rbs b/sig/preview_service/coordinator.rbs new file mode 100644 index 0000000..0e56acc --- /dev/null +++ b/sig/preview_service/coordinator.rbs @@ -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 diff --git a/sig/preview_service/grid.rbs b/sig/preview_service/grid.rbs new file mode 100644 index 0000000..e9c3bff --- /dev/null +++ b/sig/preview_service/grid.rbs @@ -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 diff --git a/sig/preview_service/image_fetcher_service.rbs b/sig/preview_service/image_fetcher_service.rbs new file mode 100644 index 0000000..c544963 --- /dev/null +++ b/sig/preview_service/image_fetcher_service.rbs @@ -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