From e3a44ca0d525d714dbb484579328a0e875e4688c Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sat, 18 Jan 2025 09:08:15 -0800 Subject: [PATCH] Implement embed images (#173) * Add mini_magick and rufus-scheduler * Expose attributes and add sigs to AwsService * Get Party ready for preview state * Added new fields for preview state and generated_at timestamp * Add preview state enum to model * Add preview_relevant_changes? after_commit hook * Add jobs for generating and cleaning up party previews * Add new endpoints to PartiesController * `preview` shows the preview and queues it up for generation if it doesn't exist yet * `regenerate_preview` allows the party owner to force regeneration of previews * Schedule jobs * Stalled jobs are checked every 5 minutes * Failed jobs are retried every hour * Old preview jobs are cleaned up daily * Add the preview service This is where the bulk of the work is. This service renders out the preview images bit by bit. Currently we render the party name, creator, job icon, and weapon grid. This includes signatures and some fonts. --- Gemfile | 6 + Gemfile.lock | 11 + app/assets/fonts/Gk-Bd.otf | Bin 0 -> 79336 bytes app/assets/fonts/Gk-Rg.otf | Bin 0 -> 72072 bytes app/controllers/api/v1/parties_controller.rb | 24 ++ app/jobs/application_job.rb | 4 + app/jobs/cleanup_party_previews_job.rb | 11 + app/jobs/generate_party_preview_job.rb | 75 ++++ app/models/party.rb | 23 ++ app/services/aws_service.rb | 2 + app/services/preview_service/canvas.rb | 173 ++++++++ app/services/preview_service/coordinator.rb | 369 ++++++++++++++++++ app/services/preview_service/grid.rb | 107 +++++ .../preview_service/image_fetcher_service.rb | 69 ++++ .../preview_generation_monitor.rb | 54 +++ config/initializers/scheduler.rb | 18 + config/routes.rb | 18 + ...18135254_add_preview_columns_to_parties.rb | 9 + db/schema.rb | 8 +- sig/aws_service.rbs | 19 + sig/preview_service/canvas.rbs | 23 ++ sig/preview_service/coordinator.rbs | 74 ++++ sig/preview_service/grid.rbs | 18 + sig/preview_service/image_fetcher_service.rbs | 26 ++ 24 files changed, 1139 insertions(+), 2 deletions(-) create mode 100644 app/assets/fonts/Gk-Bd.otf create mode 100644 app/assets/fonts/Gk-Rg.otf create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/cleanup_party_previews_job.rb create mode 100644 app/jobs/generate_party_preview_job.rb create mode 100644 app/services/preview_service/canvas.rb create mode 100644 app/services/preview_service/coordinator.rb create mode 100644 app/services/preview_service/grid.rb create mode 100644 app/services/preview_service/image_fetcher_service.rb create mode 100644 app/services/preview_service/preview_generation_monitor.rb create mode 100644 config/initializers/scheduler.rb create mode 100644 db/migrate/20250118135254_add_preview_columns_to_parties.rb create mode 100644 sig/aws_service.rbs create mode 100644 sig/preview_service/canvas.rbs create mode 100644 sig/preview_service/coordinator.rbs create mode 100644 sig/preview_service/grid.rbs create mode 100644 sig/preview_service/image_fetcher_service.rbs 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 0000000000000000000000000000000000000000..0300e43771a7d1edb217934f519b586ccbe56963 GIT binary patch literal 79336 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 literal 0 HcmV?d00001 diff --git a/app/assets/fonts/Gk-Rg.otf b/app/assets/fonts/Gk-Rg.otf new file mode 100644 index 0000000000000000000000000000000000000000..3c5cfb7f5a508b7adeffed56cb17abe90707e081 GIT binary patch literal 72072 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 XuhEC_01qwO;n&L?e!Z-^m-&AHmep8# literal 0 HcmV?d00001 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