Fix image embeds 3 (#176)

* Add default preview images

* Update application.rb

* Adds app assets path in API mode
* Cleans up file

* Create assets.rb

An initializer for font assets (for image generation)

* Updates to Canvas and Coordinator

* Update parties_controller.rb

* Adds retry header if generation is still in progress
* Streams S3 content instead of redirecting to prevent 302

* Update coordinator.rbs

* Create previews.rake

A rake task for generating images offline

* Add commands to build phase
This commit is contained in:
Justin Edmund 2025-01-20 03:55:22 -08:00 committed by GitHub
parent ad2e2cc028
commit 11d324efe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 337 additions and 123 deletions

View file

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

View file

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

View file

@ -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}")

View file

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

View file

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

106
lib/tasks/previews.rake Normal file
View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

View file

@ -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"

View file

@ -1,3 +1,5 @@
# sig/services/preview_service/coordinator.rbs
module PreviewService
class Coordinator
PREVIEW_FOLDER: String
@ -13,62 +15,64 @@ module PreviewService
@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