diff --git a/app/controllers/api/v1/parties_controller.rb b/app/controllers/api/v1/parties_controller.rb index 409abf3..0951a10 100644 --- a/app/controllers/api/v1/parties_controller.rb +++ b/app/controllers/api/v1/parties_controller.rb @@ -117,10 +117,41 @@ module Api end def preview - party = Party.find_by!(shortcode: params[:id]) + coordinator = PreviewService::Coordinator.new(@party) - preview_service = PreviewService::Coordinator.new(party) - redirect_to preview_service.preview_url + if coordinator.generation_in_progress? + response.headers['Retry-After'] = '2' + default_path = Rails.root.join('public', 'default-previews', "#{@party.element || 'default'}.png") + send_file default_path, + type: 'image/png', + disposition: 'inline' + return + end + + # Try to get the preview or send default + begin + if Rails.env.production? + # Stream S3 content instead of redirecting + s3_object = coordinator.get_s3_object + send_data s3_object.body.read, + filename: "#{@party.shortcode}.png", + type: 'image/png', + disposition: 'inline' + else + # In development, serve from local filesystem + send_file coordinator.local_preview_path, + type: 'image/png', + disposition: 'inline' + end + rescue Aws::S3::Errors::NoSuchKey + # Schedule generation if needed + coordinator.schedule_generation unless coordinator.generation_in_progress? + + # Return default preview while generating + send_file Rails.root.join('public', 'default-previews', "#{@party.element || 'default'}.png"), + type: 'image/png', + disposition: 'inline' + end end def regenerate_preview diff --git a/app/services/preview_service/canvas.rb b/app/services/preview_service/canvas.rb index a632635..6cd1241 100644 --- a/app/services/preview_service/canvas.rb +++ b/app/services/preview_service/canvas.rb @@ -44,11 +44,25 @@ module PreviewService end def add_text(image, party_name, job_icon: nil, user: nil, **options) + party_name = party_name.to_s.strip + party_name = 'Untitled' if party_name.empty? + 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 + # Try multiple font locations + font_locations = [ + Rails.root.join('app', 'assets', 'fonts', 'Gk-Bd.otf').to_s, + Rails.root.join('public', 'assets', 'fonts', 'Gk-Bd.otf').to_s + ] + + @font_path = font_locations.find { |path| File.exist?(path) } + + unless @font_path + Rails.logger.error("Font file not found in any location: #{font_locations.join(', ')}") + raise "Font file not found" + end + Rails.logger.info("Using font path: #{@font_path}") unless File.exist?(@font_path) Rails.logger.error("Font file not found at: #{@font_path}") @@ -94,16 +108,18 @@ module PreviewService 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.gravity 'NorthWest' c.fill font_color + c.font @font_path c.pointsize font_size - c.draw "text #{text_x},#{text_y} '#{party_name}'" + # Escape quotes and use pango markup for better text handling + c.annotate "0x0+#{text_x}+#{text_y}", party_name.gsub('"', '\"') end + image end @@ -154,7 +170,13 @@ module PreviewService image end - def measure_text(text, font_size, font: 'Arial') + def measure_text(text, font_size, font: @font_path) + # Ensure text is not empty and is properly escaped + text = text.to_s.strip + text = 'Untitled' if text.empty? + + # Escape text for shell command + escaped_text = text.gsub(/'/, "'\\\\''") # Create a temporary file for the text measurement temp_file = Tempfile.new(['text_measure', '.png']) @@ -167,7 +189,7 @@ module PreviewService '-fill', 'black', '-font', font, '-pointsize', font_size.to_s, - "label:#{text}", + "label:'#{escaped_text}'", # Quote the text temp_file.path ] @@ -181,15 +203,15 @@ module PreviewService height: image.height, width: image.width } + rescue => e + Rails.logger.error "Text measurement error: #{e.message}" + # Fallback dimensions + { height: 50, width: 200 } 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 index 90409f7..15e308a 100644 --- a/app/services/preview_service/coordinator.rb +++ b/app/services/preview_service/coordinator.rb @@ -8,6 +8,8 @@ module PreviewService GENERATION_TIMEOUT = 5.minutes LOCAL_STORAGE_PATH = Rails.root.join('storage', 'party-previews') + # Public Interface - Core Operations + # Initialize the party preview service # # @param party [Party] The party to generate a preview for @@ -103,6 +105,8 @@ module PreviewService end # Deletes the existing preview image for the party + # + # @return [void] def delete_preview if Rails.env.production? delete_s3_preview @@ -118,6 +122,8 @@ module PreviewService Rails.logger.error("Failed to delete preview for party #{@party.id}: #{e.message}") end + # State Management - Public + # Determines if a new preview should be generated # # @return [Boolean] True if a new preview should be generated, false otherwise @@ -150,15 +156,43 @@ module PreviewService false end - private + # Checks if a preview generation is currently in progress + # + # @return [Boolean] True if a preview is being generated, false otherwise + def generation_in_progress? + in_progress = Rails.cache.exist?("party_preview_generating_#{@party.id}") + Rails.logger.info("Cache key check for generation_in_progress: #{in_progress}") + in_progress + end - # Sets up the appropriate storage system based on environment - def setup_storage - # Always initialize AWS service for potential image fetching - @aws_service = AwsService.new + # Retrieves the S3 object for the party's preview image + # + # @return [Aws::S3::Types::GetObjectOutput] S3 object containing the preview image + # @raise [Aws::S3::Errors::NoSuchKey] If the preview image doesn't exist in S3 + # @raise [Aws::S3::Errors::NoSuchBucket] If the configured bucket doesn't exist + def get_s3_object + @aws_service.s3_client.get_object( + bucket: @aws_service.bucket, + key: preview_key + ) + end - # Create local storage paths in development - FileUtils.mkdir_p(LOCAL_STORAGE_PATH) unless Dir.exist?(LOCAL_STORAGE_PATH.to_s) + # Schedules a background job to generate the preview + # + # @return [void] + def schedule_generation + GeneratePartyPreviewJob + .set(wait: 30.seconds) + .perform_later(@party.id) + + @party.update!(preview_state: :queued) + end + + # Returns the full path for storing preview images locally + # + # @return [Pathname] Full path where the preview image should be stored + def local_preview_path + LOCAL_STORAGE_PATH.join(preview_filename) end # Creates the preview image for the party @@ -225,6 +259,21 @@ module PreviewService image end + private + + # Sets up the appropriate storage system based on environment + # + # @return [void] + def setup_storage + # Always initialize AWS service for potential image fetching + @aws_service = AwsService.new + + # Create local storage paths in development + FileUtils.mkdir_p(LOCAL_STORAGE_PATH) unless Dir.exist?(LOCAL_STORAGE_PATH.to_s) + end + + # Image Generation Pipeline + # Adds the job icon to the preview image # # @param image [MiniMagick::Image] The base image @@ -241,6 +290,7 @@ module PreviewService # Organizes and draws weapons on the preview image # # @param image [MiniMagick::Image] The base image + # @param grid_layout [Hash] The layout configuration for the grid # @return [MiniMagick::Image] The updated image with weapons def organize_and_draw_weapons(image, grid_layout) mainhand_weapon = @party.weapons.find(&:mainhand) @@ -277,9 +327,12 @@ module PreviewService end end + # Storage Operations + # Saves the preview image to the appropriate storage system # # @param image [MiniMagick::Image] The image to save + # @return [void] def save_preview(image) if Rails.env.production? upload_to_s3(image) @@ -291,14 +344,14 @@ module PreviewService # Uploads the preview image to S3 # # @param image [MiniMagick::Image] The image to upload + # @return [void] def upload_to_s3(image) - temp_file = Tempfile.new(['preview', '.png']) + temp_file = Tempfile.new(%w[preview .png]) begin image.write(temp_file.path) - # Use timestamped filename similar to local storage - timestamp = Time.current.strftime('%Y%m%d%H%M%S') - key = "#{PREVIEW_FOLDER}/#{@party.shortcode}_#{timestamp}.png" + # Use fixed key without timestamp + key = "#{PREVIEW_FOLDER}/#{@party.shortcode}.png" File.open(temp_file.path, 'rb') do |file| @aws_service.s3_client.put_object( @@ -310,7 +363,6 @@ module PreviewService ) end - # Optionally, store this key on the party record if needed for retrieval @party.update!(preview_s3_key: key) ensure temp_file.close @@ -321,29 +373,18 @@ module PreviewService # Saves the preview image to local storage # # @param image [MiniMagick::Image] The image to save + # @return [void] 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 + # Path & URL Generation - # Returns the full path for storing preview images locally + # Generates a filename for the preview image # - # @return [Pathname] Full path where the preview image should be stored - def local_preview_path - LOCAL_STORAGE_PATH.join(preview_filename) + # @return [String] Filename for the preview image + def preview_filename + "#{@party.shortcode}.png" end # Returns the URL for accessing locally stored preview images @@ -364,6 +405,8 @@ module PreviewService "#{PREVIEW_FOLDER}/#{@party.shortcode}.png" end + # Preview State Management + # Checks if a preview image exists for the party # # @return [Boolean] True if a preview exists, false otherwise @@ -371,10 +414,9 @@ module PreviewService return false unless @party.preview_state == 'generated' if Rails.env.production? - @aws_service.s3_client.head_object(bucket: S3_BUCKET, key: preview_key) - true + @aws_service.file_exists?(preview_key) else - !Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).empty? + !Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}.png").to_s).empty? end rescue Aws::S3::Errors::NotFound false @@ -387,22 +429,15 @@ module PreviewService signer = Aws::S3::Presigner.new(client: @aws_service.s3_client) signer.presigned_url( :get_object, - bucket: S3_BUCKET, + bucket: @aws_service.bucket, key: preview_key, - expires_in: 1.hour + expires_in: 1.hour.to_i ) end - # Checks if a preview generation is currently in progress - # - # @return [Boolean] True if a preview is being generated, false otherwise - def generation_in_progress? - in_progress = Rails.cache.exist?("party_preview_generating_#{@party.id}") - Rails.logger.info("Cache key check for generation_in_progress: #{in_progress}") - in_progress - end - # Marks the preview generation as in progress + # + # @return [void] def set_generation_in_progress Rails.cache.write( "party_preview_generating_#{@party.id}", @@ -412,18 +447,15 @@ module PreviewService end # Clears the in-progress flag for preview generation + # + # @return [void] def clear_generation_in_progress Rails.cache.delete("party_preview_generating_#{@party.id}") end - # Schedules a background job to generate the preview - def schedule_generation - GeneratePartyPreviewJob - .set(wait: 30.seconds) - .perform_later(@party.id) + # Job Scheduling - @party.update!(preview_state: :queued) - end + # URL Generation # Provides a default preview URL based on party attributes # @@ -436,24 +468,33 @@ module PreviewService end end + # Cleanup Operations + # Deletes the preview from S3 + # + # @return [void] def delete_s3_preview @aws_service.s3_client.delete_object( - bucket: S3_BUCKET, + bucket: @aws_service.bucket, key: preview_key ) end # Deletes local preview files + # + # @return [void] def delete_local_previews Dir.glob(LOCAL_STORAGE_PATH.join("#{@party.shortcode}_*.png").to_s).each do |file| File.delete(file) end end + # Error Handling + # Handles errors during preview generation # # @param error [Exception] The error that occurred + # @return [void] def handle_preview_generation_error(error) Rails.logger.error("Preview generation failed for party #{@party.id}") Rails.logger.error("Error: #{error.class} - #{error.message}") diff --git a/config/application.rb b/config/application.rb index 399fabe..6d35568 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,42 +1,35 @@ require_relative "boot" require "rails" -# Pick the frameworks you want: -require "active_model/railtie" -require "active_job/railtie" -require "active_record/railtie" -require "active_storage/engine" -require "action_controller/railtie" -# require "action_mailer/railtie" -# require "action_mailbox/engine" -require "action_text/engine" -require "action_view/railtie" -require "action_cable/engine" -require "rails/test_unit/railtie" -# Require the gems listed in Gemfile, including any gems -# you've limited to :test, :development, or :production. +# Include only the Rails frameworks we need +require "active_model/railtie" # Basic model functionality +require "active_job/railtie" # Background job processing +require "active_record/railtie" # Database support +require "active_storage/engine" # File upload and storage +require "action_controller/railtie" # API controller support +require "action_text/engine" # Rich text handling +require "action_view/railtie" # View rendering (needed for some API responses) +require "rails/test_unit/railtie" # Testing framework + +# Load gems from Gemfile Bundler.require(*Rails.groups) module HenseiApi class Application < Rails::Application - # Initialize configuration defaults for originally generated Rails version. + # Use Rails 7.0 defaults config.load_defaults 7.0 - # Configuration for the application, engines, and railties goes here. - # - # These settings can be overridden in specific environments using the files - # in config/environments, which are processed later. - # - # config.time_zone = "Central Time (US & Canada)" - # config.eager_load_paths << Rails.root.join("extras") - + # Configure autoloading config.autoload_paths << Rails.root.join("lib") config.eager_load_paths << Rails.root.join("lib") - # Only loads a smaller set of middleware suitable for API only apps. - # Middleware like session, flash, cookies can be added back manually. - # Skip views, helpers and assets when generating a new resource. + # Configure asset handling for API mode + config.paths["app/assets"] ||= [] + config.paths["app/assets"].unshift(Rails.root.join("app", "assets").to_s) + config.assets.paths << Rails.root.join("app", "assets", "fonts") + + # API-only application configuration config.api_only = true end end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 0000000..ade2ca8 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,12 @@ +Rails.application.config.assets.precompile += %w( .otf ) + +# Ensure fonts directory exists in production +fonts_dir = Rails.root.join('public', 'assets', 'fonts') +FileUtils.mkdir_p(fonts_dir) unless File.directory?(fonts_dir) + +# Copy fonts to public directory in production +if Rails.env.production? + Dir[Rails.root.join('app', 'assets', 'fonts', '*')].each do |font| + FileUtils.cp(font, fonts_dir) if File.file?(font) + end +end diff --git a/lib/tasks/previews.rake b/lib/tasks/previews.rake new file mode 100644 index 0000000..eded891 --- /dev/null +++ b/lib/tasks/previews.rake @@ -0,0 +1,106 @@ +namespace :previews do + desc 'Generate and upload missing preview images' + task generate_all: :environment do + coordinator_class = PreviewService::Coordinator + aws_service = AwsService.new + + # Find all parties without previews + parties = Party.where(preview_state: ['pending', 'failed', nil]) + total = parties.count + + puts "Found #{total} parties needing preview generation" + + parties.find_each.with_index(1) do |party, index| + puts "[#{index}/#{total}] Processing party #{party.shortcode}..." + + begin + coordinator = coordinator_class.new(party) + temp_file = Tempfile.new(['preview', '.png']) + + # Create preview image + image = coordinator.create_preview_image + image.write(temp_file.path) + + # Upload to S3 + key = "previews/#{party.shortcode}.png" + File.open(temp_file.path, 'rb') do |file| + aws_service.s3_client.put_object( + bucket: aws_service.bucket, + key: key, + body: file, + content_type: 'image/png', + acl: 'private' + ) + end + + # Update party state + party.update!( + preview_state: :generated, + preview_s3_key: key, + preview_generated_at: Time.current + ) + + puts " ✓ Preview generated and uploaded to S3" + rescue => e + puts " ✗ Error: #{e.message}" + ensure + temp_file&.close + temp_file&.unlink + end + end + + puts "\nPreview generation complete" + end + + desc 'Regenerate all preview images' + task regenerate_all: :environment do + coordinator_class = PreviewService::Coordinator + aws_service = AwsService.new + + parties = Party.all + total = parties.count + + puts "Found #{total} parties to regenerate" + + parties.find_each.with_index(1) do |party, index| + puts "[#{index}/#{total}] Processing party #{party.shortcode}..." + + begin + coordinator = coordinator_class.new(party) + temp_file = Tempfile.new(['preview', '.png']) + + # Create preview image + image = coordinator.create_preview_image + image.write(temp_file.path) + + # Upload to S3 + key = "previews/#{party.shortcode}.png" + File.open(temp_file.path, 'rb') do |file| + aws_service.s3_client.put_object( + bucket: aws_service.bucket, + key: key, + body: file, + content_type: 'image/png', + acl: 'private' + ) + end + + # Update party state + party.update!( + preview_state: :generated, + preview_s3_key: key, + preview_generated_at: Time.current + ) + + puts " ✓ Preview regenerated and uploaded to S3" + rescue => e + puts " ✗ Error: #{e.message}" + ensure + temp_file&.close + temp_file&.unlink + end + end + + puts "\nPreview regeneration complete" + end +end diff --git a/public/default-previews/1.png b/public/default-previews/1.png new file mode 100644 index 0000000..cd6c706 Binary files /dev/null and b/public/default-previews/1.png differ diff --git a/public/default-previews/2.png b/public/default-previews/2.png new file mode 100644 index 0000000..27faaa2 Binary files /dev/null and b/public/default-previews/2.png differ diff --git a/public/default-previews/3.png b/public/default-previews/3.png new file mode 100644 index 0000000..5114637 Binary files /dev/null and b/public/default-previews/3.png differ diff --git a/public/default-previews/4.png b/public/default-previews/4.png new file mode 100644 index 0000000..abd271d Binary files /dev/null and b/public/default-previews/4.png differ diff --git a/public/default-previews/5.png b/public/default-previews/5.png new file mode 100644 index 0000000..31101dd Binary files /dev/null and b/public/default-previews/5.png differ diff --git a/public/default-previews/6.png b/public/default-previews/6.png new file mode 100644 index 0000000..d68e9d7 Binary files /dev/null and b/public/default-previews/6.png differ diff --git a/railway.toml b/railway.toml index ee5c10a..c899038 100644 --- a/railway.toml +++ b/railway.toml @@ -22,6 +22,11 @@ dependsOn = ["setup"] [phases.build] dependsOn = ["install"] +cmds = [ + "mkdir -p public/assets/fonts", + "cp -r app/assets/fonts/* public/assets/fonts/", + "bundle exec rake assets:precompile" +] [start] cmd = "bin/rails server" diff --git a/sig/preview_service/coordinator.rbs b/sig/preview_service/coordinator.rbs index 0e56acc..94ea739 100644 --- a/sig/preview_service/coordinator.rbs +++ b/sig/preview_service/coordinator.rbs @@ -1,3 +1,5 @@ +# sig/services/preview_service/coordinator.rbs + module PreviewService class Coordinator PREVIEW_FOLDER: String @@ -6,69 +8,71 @@ module PreviewService 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 initialize: (party: Party) -> void - def preview_url: -> String + def get_s3_object: () -> Aws::S3::Types::GetObjectOutput - def generate_preview: -> bool + def preview_url: () -> String - def force_regenerate: -> bool + def generate_preview: () -> bool - def delete_preview: -> void + def force_regenerate: () -> bool + + def delete_preview: () -> void + + def should_generate?: () -> bool + + def generation_in_progress?: () -> bool + + def create_preview_image: () -> MiniMagick::Image private - def create_preview_image: -> MiniMagick::Image + def setup_storage: () -> void - def add_job_icon: (MiniMagick::Image image, MiniMagick::Image job_icon) -> MiniMagick::Image + def add_job_icon: (image: MiniMagick::Image, job_icon: MiniMagick::Image) -> MiniMagick::Image - def organize_and_draw_weapons: (MiniMagick::Image image) -> MiniMagick::Image + def organize_and_draw_weapons: (image: MiniMagick::Image, grid_layout: Hash[Symbol, untyped]) -> MiniMagick::Image - def draw_mainhand_weapon: (MiniMagick::Image image, MiniMagick::Image weapon_image) -> MiniMagick::Image + def draw_mainhand_weapon: (image: MiniMagick::Image, weapon_image: MiniMagick::Image) -> MiniMagick::Image - def save_preview: (MiniMagick::Image image) -> void + def save_preview: (image: MiniMagick::Image) -> void - def setup_storage: -> void + def upload_to_s3: (image: MiniMagick::Image) -> void - def upload_to_s3: (MiniMagick::Image image) -> void + def save_to_local_storage: (image: MiniMagick::Image) -> void - def save_to_local_storage: (MiniMagick::Image image) -> void + def preview_filename: () -> String - def preview_filename: -> String + def local_preview_path: () -> Pathname - def local_preview_path: -> Pathname + def local_preview_url: () -> String - def local_preview_url: -> String + def preview_key: () -> String - def preview_key: -> String + def preview_exists?: () -> bool - def preview_exists?: -> bool + def generate_s3_url: () -> String - def generate_s3_url: -> String + def set_generation_in_progress: () -> void - def should_generate?: -> bool + def clear_generation_in_progress: () -> void - def generation_in_progress?: -> bool + def schedule_generation: () -> void - def set_generation_in_progress: -> void + def default_preview_url: () -> String - def clear_generation_in_progress: -> void + def delete_s3_preview: () -> void - def schedule_generation: -> void + def delete_local_previews: () -> void - def default_preview_url: -> String - - def delete_s3_preview: -> void - - def delete_local_previews: -> void - - def handle_preview_generation_error: (Exception error) -> void + def handle_preview_generation_error: (error: Exception) -> void end end