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/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/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