Class: Image
- Inherits:
-
DigitalAsset
- Object
- ActiveRecord::Base
- ApplicationRecord
- DigitalAsset
- Image
- Defined in:
- app/models/image.rb
Overview
== Schema Information
Table name: digital_assets
Database name: primary
id :integer not null, primary key
ai_metadata_suggestions :jsonb
ai_visual_description :text
air_date :date
asset :jsonb
attachment_format :string(10)
attachment_height :integer
attachment_mime_type :string
attachment_name :string
attachment_size :integer
attachment_uid :string
attachment_width :integer
background_color :string
category :string(255)
cloudflare_data :jsonb not null
cloudflare_uid :string
duration_in_seconds :integer
expanded_description :text
fingerprint :bigint
fingerprint_legacy :string
image_colorspace :string
image_dpi :integer
inactive :boolean default(FALSE), not null
linked_assets_uids :string default([]), is an Array
locales :string default([]), not null, is an Array
location :string
merged_from_ids :integer default([]), is an Array
meta_description :text
meta_keywords :string
meta_title :string(255)
notes :text
position :integer default(100), not null
poster_format :string
poster_mime_type :string
poster_name :string
poster_offset :integer
poster_uid :string
reference_number :string
series :string
slug :string(140)
source :string
structured_transcript_json :jsonb
sub_header :string(255)
thumbnail_url :string
title :string(255)
transcribed_at :datetime
transcript :text
transcription_state :integer default(0)
translations :jsonb
type :string
url :string(255)
video_has_no_spoken_words :boolean default(FALSE)
vision_analyzed_at :datetime
vision_model_used :string
youtube_caption_synced_at :datetime
youtube_chapters_draft :jsonb
youtube_chapters_generation_error :text
youtube_chapters_generation_status :string
youtube_description :string
youtube_privacy_status :string
youtube_synced_at :datetime
youtube_title :string
youtube_upload_date :datetime
youtube_upload_status :string
created_at :datetime not null
updated_at :datetime not null
assemblyai_transcript_id :string
asset_file_id :string
cloudinary_asset_id :string
creator_id :integer
legacy_wistia_id :string(255)
poster_image_id :integer
purge_cache_request_id :string
updater_id :integer
youtube_id :string
youtube_thumbnail_image_id :integer
Indexes
by_type_inactive_id (type,inactive,id)
index_digital_assets_on_asset_file_id (asset_file_id) UNIQUE
index_digital_assets_on_cloudflare_uid (cloudflare_uid)
index_digital_assets_on_creator_id (creator_id)
index_digital_assets_on_inactive (inactive)
index_digital_assets_on_merged_from_ids (merged_from_ids) USING gin
index_digital_assets_on_poster_image_id (poster_image_id)
index_digital_assets_on_poster_offset (poster_offset)
index_digital_assets_on_slug (slug)
index_digital_assets_on_source (source)
index_digital_assets_on_transcription_state (transcription_state)
index_digital_assets_on_translations (translations) USING gin
index_digital_assets_on_type_and_slug (type,slug) UNIQUE
index_digital_assets_on_updater_id (updater_id)
index_digital_assets_on_url (url)
index_digital_assets_on_vision_analyzed_at (vision_analyzed_at)
index_digital_assets_on_youtube_thumbnail_image_id (youtube_thumbnail_image_id)
index_images_on_fingerprint (fingerprint) WHERE (((type)::text = 'Image'::text) AND (fingerprint IS NOT NULL))
type_category (type,category)
type_entity_id (type,legacy_wistia_id)
type_title (type,title)
Foreign Keys
fk_rails_... (creator_id => parties.id)
fk_rails_... (poster_image_id => digital_assets.id) ON DELETE => nullify
fk_rails_... (updater_id => parties.id)
fk_rails_... (youtube_thumbnail_image_id => digital_assets.id) ON DELETE => nullify
Defined Under Namespace
Classes: BlogLiquidImageFixer, DragonflyMediaUrlReplacer, ExclusiveTagError, ImageShiftingService, ImageUrlScrubber, ImagekitUrlReplacer, ImagekitUrlWithLocaleReplacer, LegacyImageParamTranslator, SourceCodeScrubber, Transform, UploadPdfToImagekit
Constant Summary collapse
- EXCLUSIVE_TAG_PREFIXES =
Tag prefixes that enforce one-image-per-tag uniqueness.
e.g. only one Image can hold "banner-for-floor-heating-bathroom-page" at a time. %w[banner-for- og-image-for-].freeze
- UPSCALE_TAG =
Tags applied to images after AI upscaling
'imagekit-upscaled'.freeze
- TOPAZ_UPSCALE_TAG =
'topaz-upscaled'.freeze
- ALL_UPSCALE_TAGS =
[UPSCALE_TAG, TOPAZ_UPSCALE_TAG].freeze
- POPULAR_TAGS_FORM_HINT =
Hint for image tag checkboxes (Popular Tags) — PDP stills use WYS profiles on the item.
'Website PDP still images and cards: use WYS image profiles on the item (Image Profile Manager), not tags. ' \ 'for-product-page: legacy/secondary still-image routing. for-support-page: support section. ' \ 'no-index: exclude from sitemaps.'.freeze
- PROVENANCE_TAGS =
Processing / provenance tags that describe what was done TO an image rather
than what the image depicts. These must never be inherited by a generated
variation — inheriting 'topaz-upscaled' would fool the upscale eligibility
check into thinking the new image has already been upscaled. ([*ALL_UPSCALE_TAGS, 'ai-generated', 'upscale-proposal']).freeze
- UPSCALE_MAX_DIMENSION =
Maximum dimension (longest edge) for upscale eligibility
Images larger than this are already high-resolution 1024- UPSCALE_MAX_INPUT_PIXELS =
ImageKit's maximum input resolution for upscaling (16 megapixels)
16_000_000- UPSCALE_MIN_DIMENSION =
Minimum dimension for "good candidate" upscaling
Images smaller than this are likely thumbnails/icons with insufficient detail 200- UPSCALE_MIN_FILE_SIZE =
Minimum file size (bytes) for good upscale candidates
Very small files are likely simple graphics with little to upscale 15_000- UPSCALE_EXCLUDE_TAGS =
Tags that indicate images are auto-generated or low-detail
These are poor upscale candidates %w[ video-poster pdf-thumbnail auto-generated installation-plan room-configuration icon thumbnail logo ].freeze
Constants included from Models::Embeddable
Models::Embeddable::DEFAULT_MODEL, Models::Embeddable::MAX_CONTENT_LENGTH
Constants included from Models::Imageable
Models::Imageable::STANDARD_SIZES, Models::Imageable::STANDARD_THUMBNAIL_SIZE, Models::Imageable::VALID_IMAGE_URL_OPTIONS
Constants inherited from DigitalAsset
DigitalAsset::HIDDEN_TAGS, DigitalAsset::POPULAR_TAGS
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Instance Attribute Summary collapse
-
#crop_h ⇒ Object
Returns the value of attribute crop_h.
-
#crop_w ⇒ Object
Returns the value of attribute crop_w.
-
#crop_x ⇒ Object
Returns the value of attribute crop_x.
-
#crop_y ⇒ Object
Returns the value of attribute crop_y.
-
#make_primary_item_image ⇒ Object
Returns the value of attribute make_primary_item_image.
-
#new_filename ⇒ Object
Returns the value of attribute new_filename.
-
#new_image ⇒ Object
Returns the value of attribute new_image.
-
#preserve_original_file ⇒ Object
Returns the value of attribute preserve_original_file.
-
#skip_imagekit_deletion ⇒ Object
Returns the value of attribute skip_imagekit_deletion.
-
#skip_notify ⇒ Object
Returns the value of attribute skip_notify.
Attributes inherited from DigitalAsset
#force_new_slug, #refresh_cache, #title, #url
Has many collapse
- #digital_assets_duplicates ⇒ ActiveRecord::Relation<DigitalAssetsDuplicate>
- #embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
-
#image_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::ImageEmbedding>
Direct association to image embeddings partition This avoids STI polymorphic issues where Rails uses 'DigitalAsset' instead of 'Image'.
- #image_profiles ⇒ ActiveRecord::Relation<ImageProfile>
- #preview_image_articles ⇒ ActiveRecord::Relation<Article>
- #primary_item_images ⇒ ActiveRecord::Relation<Item>
-
#related_image_links ⇒ ActiveRecord::Relation<RelatedImage>
Related images (upscaled versions, duplicates, variants) Forward relationships: this image is the original/source.
- #related_images ⇒ ActiveRecord::Relation<RelatedImage>
- #reviews_io_images ⇒ ActiveRecord::Relation<ReviewsIoImage>
- #reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
-
#source_image_links ⇒ ActiveRecord::Relation<RelatedImage>
Reverse relationships: this image is derived from another.
- #source_images ⇒ ActiveRecord::Relation<SourceImage>
- #video_posters ⇒ ActiveRecord::Relation<Video>
Methods included from Models::Embeddable
Methods inherited from DigitalAsset
#digital_asset_product_lines, #generated_images, #product_lines, #site_maps
Methods included from Models::Taggable
Class Method Summary collapse
-
.ai_search ⇒ ActiveRecord::Relation<Image>
A relation of Images that are ai search.
- .all_tags(exclude_tags: []) ⇒ Object
-
.attachment_format_from_file_path(file_path, original_file_name = nil) ⇒ Object
This will return the file type, e.g.
-
.by_merged_from_id ⇒ ActiveRecord::Relation<Image>
A relation of Images that are by merged from id.
-
.embeddable_content_types ⇒ Object
Embeddable configuration.
-
.embedding_status ⇒ ActiveRecord::Relation<Image>
A relation of Images that are embedding status.
-
.find_by_id_or_legacy(id_or_slug) ⇒ Object
Find an image by ID, slug, or legacy merged ID Used by helpers to support hardcoded image IDs that may have been merged.
-
.find_phash_duplicates_of(target_fingerprint, exclude_id: nil, threshold: 15, limit: 20) ⇒ Array<Hash>
Class method to find duplicates of a given fingerprint Uses PostgreSQL bit operations for efficient Hamming distance calculation.
-
.fingerprint_to_hex(fingerprint) ⇒ String?
Convert bigint fingerprint to hex string for display PostgreSQL bigint is signed, but pHash is unsigned 64-bit.
-
.for_sitemap ⇒ ActiveRecord::Relation<Image>
A relation of Images that are for sitemap.
-
.hex_to_fingerprint(hex_fingerprint) ⇒ Integer?
Convert hex fingerprint string to bigint (signed) PostgreSQL bigint is signed, so hex values with high bit set (>= 0x8000000000000000) need to be converted to negative values.
-
.hybrid_search ⇒ ActiveRecord::Relation<Image>
A relation of Images that are hybrid search.
- .is_url?(string) ⇒ Boolean
- .ransackable_scopes(_auth_object = nil) ⇒ Object
- .suggested_sources_for_select ⇒ Object
- .upload_file_to_ik(file, file_name: nil, tags: [], folder: 'img/') ⇒ Object
-
.upscale_eligible ⇒ ActiveRecord::Relation<Image>
A relation of Images that are upscale eligible.
-
.video_posters ⇒ ActiveRecord::Relation<Image>
A relation of Images that are video posters.
-
.with_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with embedding.
-
.with_phash ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with phash.
-
.with_unified_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with unified embedding.
-
.with_vision ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with vision.
-
.without_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without embedding.
-
.without_phash ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without phash.
-
.without_unified_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without unified embedding.
-
.without_vision ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without vision.
Instance Method Summary collapse
-
#add_tag(tag_name) ⇒ Object
Override to enforce exclusive tag uniqueness before persisting.
-
#ai_search ⇒ ActiveRecord::Relation
AI semantic search scope using primary text embeddings Uses the ImageEmbedding partition model for proper joins with neighbor gem.
- #all_my_applicable_product_categories ⇒ Object
-
#analyze_fully!(force: false) ⇒ Object
Queue full AI analysis (pHash → Vision → Embedding).
-
#compute_visual_hash ⇒ String
Compute visual hash based on image content identifiers Used to detect when the actual image file has changed.
-
#content_for_embedding(_content_type = :primary) ⇒ Object
Returns content for text embedding generation.
- #deep_dup ⇒ Object
-
#default_upscale_format ⇒ String
Determine the default output format based on original format PNG stays PNG, everything else becomes JPEG at high quality.
-
#default_upscale_quality ⇒ Integer
Get the default quality setting for upscale Only applicable for lossy formats (JPEG, WebP).
-
#delete_from_imagekit ⇒ Object
delete from imagekit server.
- #determine_mime_type ⇒ Object
- #extract_info_from_asset ⇒ Object
-
#find_phash_duplicates(threshold: 15, limit: 20) ⇒ Array<Hash>
Find true duplicate images using perceptual hash (pHash) This detects actual duplicate/near-duplicate images (same photo, different format/size) Uses database-powered Hamming distance calculation for efficiency.
-
#find_visually_similar(limit: 5) ⇒ Array<Image>
Find visually similar images using unified embeddings (Gemini Embedding 2) Note: This finds SEMANTICALLY similar images (same concept/subject).
-
#fingerprint_hex ⇒ String?
Display the fingerprint as hex string (for UI/debugging).
- #ik_path ⇒ Object
- #ik_rename ⇒ Object
- #imagekit_tags ⇒ Object
-
#is_remote_image_valid? ⇒ Boolean
This methods checks that we have a valid image stored on the server by downloading it first and running basic checks on it.
- #mark_as_primary_item_image(specific_items = nil) ⇒ Object
-
#mark_as_upscaled! ⇒ Object
Mark this image as upscaled by adding the tag.
-
#perform_imagekit_rename ⇒ Object
Rename or copy the file on ImageKit and purge cache Called after commit when force_new_slug is set If preserve_original_file is true, copies instead of renaming (keeps original).
- #purge_cache ⇒ Object
- #purge_file_cache_status ⇒ Object
- #should_generate_new_friendly_id? ⇒ Boolean
- #should_rename_on_imagekit? ⇒ Boolean
-
#tags=(value) ⇒ Object
Override to enforce exclusive tag uniqueness before persisting.
-
#upload_new_image ⇒ Object
Before save call to do the actual uploading to imagekit new_image can be an instance of ActionDispatch::Http::UploadedFile from an upload or a plain file object.
-
#upscale_eligible? ⇒ Boolean
Check if this image is eligible for AI upscaling Eligible if: active, has dimensions, not already upscaled, within ImageKit 16MP hard limit.
-
#upscale_preview_url(format: nil, quality: 95) ⇒ String
Generate a preview URL with ImageKit's e-upscale transformation The format parameter ensures WYSIWYG preview (overrides auto-format).
-
#upscaled? ⇒ Boolean
Check if this image has already been upscaled.
Methods included from Models::HybridSearchable
Methods included from Models::Embeddable
#embeddable_locales, #embedding_content_hash, embedding_partition_class, #embedding_stale?, #embedding_type_name, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search
Methods included from Models::Imageable
#aspect_ratio, #human_size, #ik_file_name, #ik_file_name_with_extension, #ik_get_file_details, #ik_get_metadata, #ik_get_metadata_by_url, #ik_raw_url, #ik_url, #image_info, #image_url, #info, #presets_hash, #sourceset, #thumbnail_url, #to_s
Methods inherited from DigitalAsset
active, #alerts, all_locales, #all_my_items, #asset_identifier, available_banner_tags, available_og_image_tags, available_page_tags, available_page_tags_with_paths, banner_tag_for, by_category, by_item_ids, by_item_skus, by_party_ids, by_product_category_id_direct, by_product_category_id_direct_or_optional, by_product_line_id, by_product_line_id_direct, by_product_line_path, categorized, #cross_links_opportunities_to_parties, #dimensions, exclude_tags, #file_basename, images, #is_image?, #is_video?, #items, localized_for, localized_for_or_not, not_by_party_ids, not_by_product_line_id, not_by_product_line_path, og_image_tag_for, #opportunities, page_tag_for, #parties, #product_categories, #product_lines_display, #product_lines_for_sorting, #purge_edge_cache, related_to_item_id, #reviews, #sanitize_urls, #seo_title, #should_sanitize_urls?, show_hidden_tags, #slug_candidates, tag_presence, tagged_with_all, #tags_display, #thumbnail_url, #touch_related, videos, with_product_line_urls, without_product_categories, without_product_lines
Methods included from Models::Taggable
#has_tag?, normalize_tag_names, not_tagged_with, #remove_tag, #tag_list, #tag_list=, #taggable_type_for_tagging, tagged_with, #tags, tags_cloud, tags_exclude, tags_include, with_all_tags, with_any_tags, without_all_tags, without_any_tags
Methods included from Models::ItemScopable
by_product_category_id, by_product_category_id_direct, by_product_category_path, by_product_category_path_exact, by_product_category_url, by_product_category_url_exact, by_product_line_id, by_product_line_path, by_product_line_path_full, by_product_line_url, by_product_line_url_full, not_by_product_category_id, not_by_product_line_id
Methods included from Models::Auditable
#all_skipped_columns, #audit_reference_data, #creator, #should_not_save_version, #stamp_record, #updater
Methods inherited from ApplicationRecord
ransackable_associations, ransackable_attributes, ransortable_attributes, #to_relation
Methods included from Models::EventPublishable
Instance Attribute Details
#crop_h ⇒ Object
Returns the value of attribute crop_h.
201 202 203 |
# File 'app/models/image.rb', line 201 def crop_h @crop_h end |
#crop_w ⇒ Object
Returns the value of attribute crop_w.
201 202 203 |
# File 'app/models/image.rb', line 201 def crop_w @crop_w end |
#crop_x ⇒ Object
Returns the value of attribute crop_x.
201 202 203 |
# File 'app/models/image.rb', line 201 def crop_x @crop_x end |
#crop_y ⇒ Object
Returns the value of attribute crop_y.
201 202 203 |
# File 'app/models/image.rb', line 201 def crop_y @crop_y end |
#make_primary_item_image ⇒ Object
Returns the value of attribute make_primary_item_image.
201 202 203 |
# File 'app/models/image.rb', line 201 def make_primary_item_image @make_primary_item_image end |
#new_filename ⇒ Object
Returns the value of attribute new_filename.
201 202 203 |
# File 'app/models/image.rb', line 201 def new_filename @new_filename end |
#new_image ⇒ Object
Returns the value of attribute new_image.
201 202 203 |
# File 'app/models/image.rb', line 201 def new_image @new_image end |
#preserve_original_file ⇒ Object
Returns the value of attribute preserve_original_file.
201 202 203 |
# File 'app/models/image.rb', line 201 def preserve_original_file @preserve_original_file end |
#skip_imagekit_deletion ⇒ Object
Returns the value of attribute skip_imagekit_deletion.
201 202 203 |
# File 'app/models/image.rb', line 201 def skip_imagekit_deletion @skip_imagekit_deletion end |
#skip_notify ⇒ Object
Returns the value of attribute skip_notify.
201 202 203 |
# File 'app/models/image.rb', line 201 def skip_notify @skip_notify end |
Class Method Details
.ai_search ⇒ ActiveRecord::Relation<Image>
A relation of Images that are ai search. Active Record Scope
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 |
# File 'app/models/image.rb', line 367 scope :ai_search, ->(query, limit: 500, max_distance: nil) { return none if query.blank? = ContentEmbedding.(query, model: ContentEmbedding::UNIFIED_MODEL) return none unless # Format vector for SQL with explicit dimension cast dimensions = .size vector_literal = "[#{.join(',')}]" # Build query manually to ensure all Image columns are selected # Uses unified_embedding column with Gemini Embedding 2 base_query = joins(:image_embeddings) .where(content_embeddings_images: { embeddable_type: 'Image', content_type: 'unified' }) .where.not(content_embeddings_images: { unified_embedding: nil }) .select( "#{table_name}.*", Arel.sql(sanitize_sql_array([ "content_embeddings_images.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) AS neighbor_distance", vector_literal ])) ) .order(Arel.sql(sanitize_sql_array([ "content_embeddings_images.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) ASC", vector_literal ]))) # Optionally filter by distance threshold if max_distance.present? base_query = base_query.where( Arel.sql(sanitize_sql_array([ "content_embeddings_images.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) < ?", vector_literal, max_distance ])) ) end base_query.limit(limit) } |
.all_tags(exclude_tags: []) ⇒ Object
451 452 453 454 |
# File 'app/models/image.rb', line 451 def self.(exclude_tags: []) # Use the taggable concern's all_tags which handles STI correctly super end |
.attachment_format_from_file_path(file_path, original_file_name = nil) ⇒ Object
This will return the file type, e.g. jpeg, gif, png, etc.
602 603 604 605 606 607 |
# File 'app/models/image.rb', line 602 def self.(file_path, original_file_name = nil) # You could just use Rack::Mime.mime_type(File.extname(file_path)) but Marcel will actually read the first few bytes, it's used by basecamp require 'marcel' mt = Marcel::MimeType.for Pathname.new(file_path), name: original_file_name Mime::Type.lookup(mt).symbol.to_s end |
.by_merged_from_id ⇒ ActiveRecord::Relation<Image>
A relation of Images that are by merged from id. Active Record Scope
235 |
# File 'app/models/image.rb', line 235 scope :by_merged_from_id, ->(id) { where('merged_from_ids @> ARRAY[?]::integer[]', id.to_i) } |
.embeddable_content_types ⇒ Object
Embeddable configuration
776 777 778 |
# File 'app/models/image.rb', line 776 def self. [:primary] end |
.embedding_status ⇒ ActiveRecord::Relation<Image>
A relation of Images that are embedding status. Active Record Scope
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'app/models/image.rb', line 253 scope :embedding_status, ->(value) { return all if value.blank? case value when 'with_phash' then with_phash when 'without_phash' then without_phash when 'with_vision' then with_vision when 'without_vision' then without_vision when 'with_unified' then when 'without_unified' then when 'with_embedding' then when 'without_embedding' then else all end } |
.find_by_id_or_legacy(id_or_slug) ⇒ Object
Find an image by ID, slug, or legacy merged ID
Used by helpers to support hardcoded image IDs that may have been merged
431 432 433 434 435 436 437 438 439 440 |
# File 'app/models/image.rb', line 431 def self.find_by_id_or_legacy(id_or_slug) return nil if id_or_slug.blank? # First try the normal FriendlyId lookup (handles slug + current ID) image = friendly.find(id_or_slug) image if image rescue ActiveRecord::RecordNotFound # If ID was numeric, check merged_from_ids for legacy reference by_merged_from_id(id_or_slug).first if id_or_slug.to_s.match?(/\A\d+\z/) end |
.find_phash_duplicates_of(target_fingerprint, exclude_id: nil, threshold: 15, limit: 20) ⇒ Array<Hash>
Class method to find duplicates of a given fingerprint
Uses PostgreSQL bit operations for efficient Hamming distance calculation
The fingerprint column is stored as bigint (64-bit integer).
Accepts either hex string or integer as input.
1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 |
# File 'app/models/image.rb', line 1011 def self.find_phash_duplicates_of(target_fingerprint, exclude_id: nil, threshold: 15, limit: 20) return [] unless target_fingerprint.present? # Convert to signed bigint if given as hex string # Must use hex_to_fingerprint to handle unsigned→signed conversion target_int = if target_fingerprint.is_a?(String) hex_to_fingerprint(target_fingerprint) else target_fingerprint.to_i end return [] if target_int.nil? # Sanitize threshold threshold = threshold.to_i.clamp(0, 64) # Native bigint XOR, cast to bit(64) for bit_count function # bit_count() in PostgreSQL 14+ works on bit types hamming_sql = "bit_count((fingerprint # #{target_int})::bit(64))" query = active .where.not(fingerprint: nil) .where.not(id: exclude_id) .select('digital_assets.*', Arel.sql("#{hamming_sql} AS hamming_distance")) .where("#{hamming_sql} <= #{threshold}") .order(Arel.sql('hamming_distance ASC')) .limit(limit) query.map do |image| { image: image, distance: image[:hamming_distance].to_i } end end |
.fingerprint_to_hex(fingerprint) ⇒ String?
Convert bigint fingerprint to hex string for display
PostgreSQL bigint is signed, but pHash is unsigned 64-bit.
We need to handle negative values (high bit set).
1067 1068 1069 1070 1071 1072 1073 |
# File 'app/models/image.rb', line 1067 def self.fingerprint_to_hex(fingerprint) return nil unless fingerprint.present? # Convert signed to unsigned 64-bit for proper hex display unsigned = fingerprint & 0xFFFFFFFFFFFFFFFF unsigned.to_s(16).rjust(16, '0') end |
.for_sitemap ⇒ ActiveRecord::Relation<Image>
A relation of Images that are for sitemap. Active Record Scope
230 |
# File 'app/models/image.rb', line 230 scope :for_sitemap, -> { active.not_tagged_with('no-index') } |
.hex_to_fingerprint(hex_fingerprint) ⇒ Integer?
Convert hex fingerprint string to bigint (signed)
PostgreSQL bigint is signed, so hex values with high bit set (>= 0x8000000000000000)
need to be converted to negative values.
1053 1054 1055 1056 1057 1058 1059 |
# File 'app/models/image.rb', line 1053 def self.hex_to_fingerprint(hex_fingerprint) return nil unless hex_fingerprint.present? && hex_fingerprint.match?(/\A[0-9a-f]{16}\z/i) unsigned = hex_fingerprint.to_i(16) # Convert to signed: if high bit is set, subtract 2^64 unsigned >= 0x8000000000000000 ? unsigned - 0x10000000000000000 : unsigned end |
.hybrid_search ⇒ ActiveRecord::Relation<Image>
A relation of Images that are hybrid search. Active Record Scope
412 413 414 415 416 417 418 419 420 421 422 423 |
# File 'app/models/image.rb', line 412 scope :hybrid_search, ->(query, limit: 500) { return none if query.blank? ai_ids = ai_search(query, limit: limit).pluck(:id) keyword_ids = begin keyword_search(query).limit(limit).pluck(:id) rescue PgSearch::EmptyQueryError [] end rrf_ranked_relation(ai_ids, keyword_ids, limit: limit) } |
.is_url?(string) ⇒ Boolean
609 610 611 612 |
# File 'app/models/image.rb', line 609 def self.is_url?(string) uri = Addressable::URI.parse(string) %w[http https].include?(uri.scheme) end |
.ransackable_scopes(_auth_object = nil) ⇒ Object
425 426 427 |
# File 'app/models/image.rb', line 425 def self.ransackable_scopes(_auth_object = nil) super + %i[embedding_status ai_search hybrid_search upscale_eligible] end |
.suggested_sources_for_select ⇒ Object
470 471 472 |
# File 'app/models/image.rb', line 470 def self.suggested_sources_for_select Image.where.not(source: [nil, '']).order(:source).distinct.pluck(:source) end |
.upload_file_to_ik(file, file_name: nil, tags: [], folder: 'img/') ⇒ Object
550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 |
# File 'app/models/image.rb', line 550 def self.upload_file_to_ik(file, file_name: nil, tags: [], folder: 'img/') return unless file clean_file_name = file_name.nil? # We generate a new file name file_name ||= file.original_filename if file.respond_to?(:original_filename) sanitized_file_name = File.basename(file_name, File.extname(file_name)).downcase.parameterize.tr('_', '-') if clean_file_name # Append unique identifier sr = SecureRandom.base58(6).downcase sanitized_file_name << "-#{sr}" end # ImageKit 4.0: The API expects a proper file handle (Pathname, File, IO), not an UploadedFile object file_to_upload = nil begin # Convert ActionDispatch::Http::UploadedFile or Tempfile to a proper File object file_to_upload = if file.respond_to?(:path) && file.path.present? File.open(file.path, 'rb') else file end # Use ImageKitFactory helper method result = ImageKitFactory.upload_file( file: file_to_upload, file_name: sanitized_file_name, tags: , use_unique_file_name: false, folder: folder ) # Return in old format for backward compatibility with upload_new_image { response: result.to_h.deep_symbolize_keys, error: nil } rescue StandardError => e Rails.logger.error "ImageKit upload failed: #{e.}" Rails.logger.error e.backtrace.join("\n") { response: nil, error: e. } ensure # Close the file handle if we opened it file_to_upload&.close if file_to_upload.is_a?(File) && file.respond_to?(:path) end end |
.upscale_eligible ⇒ ActiveRecord::Relation<Image>
A relation of Images that are upscale eligible. Active Record Scope
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'app/models/image.rb', line 271 scope :upscale_eligible, ->(value) { return all if value.blank? case value when 'eligible' # Small images that haven't been upscaled yet (by any engine) active .where.not(attachment_width: nil) .where.not(attachment_height: nil) .where('GREATEST(attachment_width, attachment_height) < ?', UPSCALE_MAX_DIMENSION) .where('attachment_width * attachment_height < ?', UPSCALE_MAX_INPUT_PIXELS) .where("asset->>'file_path' IS NOT NULL") .not_tagged_with(ALL_UPSCALE_TAGS) when 'good_candidates' # Best candidates for upscaling: enough detail to produce good results # Excludes: thumbnails, icons, auto-generated images, very small files active .where.not(attachment_width: nil) .where.not(attachment_height: nil) .where('GREATEST(attachment_width, attachment_height) < ?', UPSCALE_MAX_DIMENSION) .where('LEAST(attachment_width, attachment_height) >= ?', UPSCALE_MIN_DIMENSION) .where('attachment_width * attachment_height < ?', UPSCALE_MAX_INPUT_PIXELS) .where('attachment_size >= ?', UPSCALE_MIN_FILE_SIZE) .where("asset->>'file_path' IS NOT NULL") .not_tagged_with(ALL_UPSCALE_TAGS) .not_tagged_with(UPSCALE_EXCLUDE_TAGS) when 'too_small' # Images too small for good upscaling (thumbnails, icons) active .where.not(attachment_width: nil) .where.not(attachment_height: nil) .where('LEAST(attachment_width, attachment_height) < ?', UPSCALE_MIN_DIMENSION) .not_tagged_with(ALL_UPSCALE_TAGS) when 'upscaled' # Images that have been upscaled by any engine tagged_with(ALL_UPSCALE_TAGS, match: :any) when 'upscaled_imagekit' # Images upscaled specifically by ImageKit tagged_with(UPSCALE_TAG) when 'upscaled_topaz' # Images upscaled specifically by Topaz Labs tagged_with(TOPAZ_UPSCALE_TAG) when 'too_large' # Images too large for upscaling (already high-res) active .where.not(attachment_width: nil) .where.not(attachment_height: nil) .where('GREATEST(attachment_width, attachment_height) >= ?', UPSCALE_MAX_DIMENSION) .not_tagged_with(ALL_UPSCALE_TAGS) else all end } |
.video_posters ⇒ ActiveRecord::Relation<Image>
A relation of Images that are video posters. Active Record Scope
231 |
# File 'app/models/image.rb', line 231 scope :video_posters, -> { tagged_with('video-poster') } |
.with_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with embedding. Active Record Scope
242 243 244 245 246 247 |
# File 'app/models/image.rb', line 242 scope :with_embedding, lambda { joins("INNER JOIN content_embeddings ON content_embeddings.embeddable_type = 'Image' AND content_embeddings.embeddable_id = digital_assets.id") .where(content_embeddings: { content_type: 'unified' }) .where.not(content_embeddings: { content_hash: nil }) .distinct } |
.with_phash ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with phash. Active Record Scope
238 |
# File 'app/models/image.rb', line 238 scope :with_phash, -> { where.not(fingerprint: nil) } |
.with_unified_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with unified embedding. Active Record Scope
326 327 328 329 330 331 |
# File 'app/models/image.rb', line 326 scope :with_unified_embedding, lambda { joins("INNER JOIN content_embeddings ON content_embeddings.embeddable_id = images.id AND content_embeddings.embeddable_type = 'Image' AND content_embeddings.content_type = 'unified' AND content_embeddings.unified_embedding IS NOT NULL") } |
.with_vision ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with vision. Active Record Scope
240 |
# File 'app/models/image.rb', line 240 scope :with_vision, -> { where.not(ai_visual_description: [nil, '']) } |
.without_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without embedding. Active Record Scope
248 249 250 |
# File 'app/models/image.rb', line 248 scope :without_embedding, lambda { where.not(id: .select(:id)) } |
.without_phash ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without phash. Active Record Scope
239 |
# File 'app/models/image.rb', line 239 scope :without_phash, -> { where(fingerprint: nil) } |
.without_unified_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without unified embedding. Active Record Scope
332 333 334 |
# File 'app/models/image.rb', line 332 scope :without_unified_embedding, lambda { where.not(id: .select(:id)) } |
.without_vision ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without vision. Active Record Scope
241 |
# File 'app/models/image.rb', line 241 scope :without_vision, -> { where(ai_visual_description: [nil, '']) } |
Instance Method Details
#add_tag(tag_name) ⇒ Object
Override to enforce exclusive tag uniqueness before persisting.
457 458 459 460 |
# File 'app/models/image.rb', line 457 def add_tag(tag_name) validate_exclusive_tag!(tag_name) super end |
#ai_search ⇒ ActiveRecord::Relation
AI semantic search scope using primary text embeddings
Uses the ImageEmbedding partition model for proper joins with neighbor gem
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 |
# File 'app/models/image.rb', line 367 scope :ai_search, ->(query, limit: 500, max_distance: nil) { return none if query.blank? = ContentEmbedding.(query, model: ContentEmbedding::UNIFIED_MODEL) return none unless # Format vector for SQL with explicit dimension cast dimensions = .size vector_literal = "[#{.join(',')}]" # Build query manually to ensure all Image columns are selected # Uses unified_embedding column with Gemini Embedding 2 base_query = joins(:image_embeddings) .where(content_embeddings_images: { embeddable_type: 'Image', content_type: 'unified' }) .where.not(content_embeddings_images: { unified_embedding: nil }) .select( "#{table_name}.*", Arel.sql(sanitize_sql_array([ "content_embeddings_images.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) AS neighbor_distance", vector_literal ])) ) .order(Arel.sql(sanitize_sql_array([ "content_embeddings_images.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) ASC", vector_literal ]))) # Optionally filter by distance threshold if max_distance.present? base_query = base_query.where( Arel.sql(sanitize_sql_array([ "content_embeddings_images.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) < ?", vector_literal, max_distance ])) ) end base_query.limit(limit) } |
#all_my_applicable_product_categories ⇒ Object
474 475 476 |
# File 'app/models/image.rb', line 474 def all_my_applicable_product_categories product_categories.flat_map(&:self_and_descendants) end |
#analyze_fully!(force: false) ⇒ Object
Queue full AI analysis (pHash → Vision → Embedding)
848 849 850 |
# File 'app/models/image.rb', line 848 def analyze_fully!(force: false) ImageFullAnalysisWorker.perform_async(id, force: force) end |
#compute_visual_hash ⇒ String
Compute visual hash based on image content identifiers
Used to detect when the actual image file has changed
947 948 949 950 951 952 953 954 |
# File 'app/models/image.rb', line 947 def compute_visual_hash identifiers = [ ik_path, updated_at&.to_i ].compact.join('|') Digest::SHA256.hexdigest(identifiers)[0..31] end |
#content_for_embedding(_content_type = :primary) ⇒ Object
Returns content for text embedding generation.
IMPORTANT: Returns nil if Vision analysis is not complete.
This enforces the dependency chain: Vision → Text embedding
Content used for embedding - includes vision description + metadata.
785 786 787 788 789 790 791 792 793 |
# File 'app/models/image.rb', line 785 def (_content_type = :primary) [ , , , , ].flatten.compact.join("\n\n") end |
#deep_dup ⇒ Object
442 443 444 445 446 447 448 449 |
# File 'app/models/image.rb', line 442 def deep_dup deep_clone( include: [:product_categories, :items, :parties, :opportunities], except: %i[created_at updated_at attachment_uid attachment_name attachment_size attachment_width attachment_height asset reference_number] ) do |original, copy| copy.title = "#{original.title} copy" if copy.is_a?(Image) end end |
#default_upscale_format ⇒ String
Determine the default output format based on original format
PNG stays PNG, everything else becomes JPEG at high quality
922 923 924 925 926 927 928 929 930 931 |
# File 'app/models/image.rb', line 922 def default_upscale_format case &.downcase when 'png' 'png' when 'webp' 'webp' else 'jpeg' end end |
#default_upscale_quality ⇒ Integer
Get the default quality setting for upscale
Only applicable for lossy formats (JPEG, WebP)
938 939 940 |
# File 'app/models/image.rb', line 938 def default_upscale_quality 95 end |
#delete_from_imagekit ⇒ Object
delete from imagekit server
698 699 700 701 702 703 704 705 706 707 708 709 710 711 |
# File 'app/models/image.rb', line 698 def delete_from_imagekit # ImageKit 4.0 returns snake_case keys: file_id instead of fileId return unless file_id = asset[:file_id] || asset[:fileId] # Use ImageKitFactory helper method begin ImageKitFactory.delete_file(file_id) # Success - purge cache purge_cache rescue StandardError => e # If we have an error due to file missing, we don't really care ErrorReporting.warning("Unable to delete file_id #{file_id} from imagekit, #{e.}") end end |
#determine_mime_type ⇒ Object
768 769 770 771 772 773 |
# File 'app/models/image.rb', line 768 def determine_mime_type return if .present? return if .blank? MIME::Types.type_for(".#{}").first&.to_s end |
#digital_assets_duplicates ⇒ ActiveRecord::Relation<DigitalAssetsDuplicate>
211 |
# File 'app/models/image.rb', line 211 has_many :digital_assets_duplicates, dependent: :destroy |
#embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
216 |
# File 'app/models/image.rb', line 216 has_many :embedded_assets, as: :asset, dependent: :destroy |
#extract_info_from_asset ⇒ Object
483 484 485 486 487 488 489 490 491 492 493 494 495 |
# File 'app/models/image.rb', line 483 def extract_info_from_asset return unless asset.present? self. = asset['width'] self. = asset['height'] # ImageKit 4.0 returns snake_case keys: file_path instead of filePath file_path = asset['file_path'] || asset['filePath'] self. ||= File.extname(file_path)[1..] if file_path self. = MIME::Types.type_for(file_path)&.first&.to_s if file_path self. = asset['name'] self. = asset['size'] self end |
#find_phash_duplicates(threshold: 15, limit: 20) ⇒ Array<Hash>
Find true duplicate images using perceptual hash (pHash)
This detects actual duplicate/near-duplicate images (same photo, different format/size)
Uses database-powered Hamming distance calculation for efficiency.
993 994 995 996 997 |
# File 'app/models/image.rb', line 993 def find_phash_duplicates(threshold: 15, limit: 20) return [] unless fingerprint.present? Image.find_phash_duplicates_of(fingerprint, exclude_id: id, threshold: threshold, limit: limit) end |
#find_visually_similar(limit: 5) ⇒ Array<Image>
Find visually similar images using unified embeddings (Gemini Embedding 2)
Note: This finds SEMANTICALLY similar images (same concept/subject).
For true duplicate detection, use #find_phash_duplicates instead.
963 964 965 966 967 968 969 970 971 972 973 974 975 976 |
# File 'app/models/image.rb', line 963 def find_visually_similar(limit: 5) unified = (:unified) return [] unless unified&..present? ContentEmbedding .where(embeddable_type: 'Image', content_type: 'unified') .where.not(unified_embedding: nil) .where.not(embeddable_id: id) .nearest_neighbors(:unified_embedding, unified., distance: :cosine) .limit(limit) .includes(:embeddable) .map(&:embeddable) .compact end |
#fingerprint_hex ⇒ String?
Display the fingerprint as hex string (for UI/debugging)
1077 1078 1079 |
# File 'app/models/image.rb', line 1077 def fingerprint_hex self.class.fingerprint_to_hex(fingerprint) end |
#ik_path ⇒ Object
478 479 480 481 |
# File 'app/models/image.rb', line 478 def ik_path # ImageKit 4.0 returns snake_case keys: file_path instead of filePath asset&.dig('file_path') || asset&.dig('filePath') end |
#ik_rename ⇒ Object
497 498 499 500 501 502 503 |
# File 'app/models/image.rb', line 497 def ik_rename # ImageKit 4.0 returns snake_case keys: file_path instead of filePath return unless (file_path = asset&.dig('file_path') || asset&.dig('filePath')) # Use ImageKitFactory helper method ImageKitFactory.rename_file(file_path: file_path, new_file_name: ik_file_name) end |
#image_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::ImageEmbedding>
Direct association to image embeddings partition
This avoids STI polymorphic issues where Rails uses 'DigitalAsset' instead of 'Image'
175 176 177 178 179 |
# File 'app/models/image.rb', line 175 has_many :image_embeddings, class_name: 'ContentEmbedding::ImageEmbedding', foreign_key: :embeddable_id, dependent: :destroy, inverse_of: :embeddable |
#image_profiles ⇒ ActiveRecord::Relation<ImageProfile>
213 |
# File 'app/models/image.rb', line 213 has_many :image_profiles, dependent: :destroy |
#imagekit_tags ⇒ Object
680 681 682 683 684 685 686 687 688 689 |
# File 'app/models/image.rb', line 680 def = [] << "image-#{id}" if persisted? << reference_number .push(*.sort) .push(*product_lines.pluck(:slug_ltree)) .push(*product_categories.pluck(:url)) .delete('dragonfly-imported') .compact.map(&:downcase).sort end |
#is_remote_image_valid? ⇒ Boolean
This methods checks that we have a valid image stored on the server
by downloading it first and running basic checks on it.
616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 |
# File 'app/models/image.rb', line 616 def is_remote_image_valid? r = false tempfile = nil begin tempfile = Down::Http.download(image_url) a = Vips::Image.new_from_file tempfile.path, access: :sequential # Basic validation, does it have a width and height r = a.width&.positive? && a.height&.positive? rescue StandardError => e logger.error "Error validating remote image #{id} - #{image_url}: #{e.}" ensure tempfile&.close tempfile&.unlink end r end |
#mark_as_primary_item_image(specific_items = nil) ⇒ Object
757 758 759 760 761 762 763 764 765 766 |
# File 'app/models/image.rb', line 757 def mark_as_primary_item_image(specific_items = nil) specific_items ||= all_my_items return if specific_items.blank? specific_items.each do |i| i.update(primary_image_id: id) i.purge_edge_cache end specific_items.size end |
#mark_as_upscaled! ⇒ Object
Mark this image as upscaled by adding the tag
886 887 888 889 890 891 |
# File 'app/models/image.rb', line 886 def mark_as_upscaled! return if upscaled? self. = ( + [UPSCALE_TAG]).uniq save! end |
#perform_imagekit_rename ⇒ Object
Rename or copy the file on ImageKit and purge cache
Called after commit when force_new_slug is set
If preserve_original_file is true, copies instead of renaming (keeps original)
508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 |
# File 'app/models/image.rb', line 508 def perform_imagekit_rename old_file_path = asset&.dig('file_path') || asset&.dig('filePath') return unless old_file_path begin new_path = old_file_path.sub(File.basename(old_file_path), ik_file_name) if preserve_original_file.to_b # Copy file to new name (preserves original) destination_folder = File.dirname(old_file_path) + '/' ImageKitFactory.copy_file( source_file_path: old_file_path, destination_path: destination_folder, new_file_name: ik_file_name ) Rails.logger.info("Image #{id} copied on ImageKit from #{old_file_path} to #{new_path} (original preserved)") else # Rename file (removes original) ik_rename Rails.logger.info("Image #{id} renamed on ImageKit from #{old_file_path} to #{new_path}") end # Update asset with new file path asset_update = asset.dup asset_update['file_path'] = new_path asset_update['filePath'] = new_path asset_update['name'] = ik_file_name update_column(:asset, asset_update) # Purge new URL from CDN cache purge_cache rescue StandardError => e Rails.logger.error("Image #{id} ImageKit rename/copy failed: #{e.}") ErrorReporting.warning("Image #{id} ImageKit rename/copy failed: #{e.}") end end |
#preview_image_articles ⇒ ActiveRecord::Relation<Article>
212 |
# File 'app/models/image.rb', line 212 has_many :preview_image_articles, inverse_of: :preview_image, class_name: 'Article', foreign_key: :preview_image_id, dependent: :nullify |
#primary_item_images ⇒ ActiveRecord::Relation<Item>
215 |
# File 'app/models/image.rb', line 215 has_many :primary_item_images, class_name: 'Item', foreign_key: :primary_image_id, dependent: :restrict_with_error, inverse_of: :primary_image |
#purge_cache ⇒ Object
713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 |
# File 'app/models/image.rb', line 713 def purge_cache return unless url = ik_raw_url # Remove the store file format, we want to wildcard on all formats, e.g # https://ik.imagekit.io/wy/img/directbuy-logo-a6vrmq.png becomes # https://ik.imagekit.io/wy/img/directbuy-logo-a6vrmq url_parsed = Addressable::URI.parse(url) url_path = url_parsed.path url_path = url_path.split('.').first url_parsed.path = "#{url_path}*" url_wild = url_parsed.to_s # Use ImageKitFactory helper method begin res = ImageKitFactory.purge_cache(url_wild) request_id = res&.request_id # ImageKit 4.0 returns object with request_id attribute self.purge_cache_request_id = request_id Rails.logger.info("Image #{id} #{ik_file_name} #{url_wild} cache purge request #{request_id} issued") { request_id: request_id, error: nil } rescue StandardError => e Rails.logger.error("Image #{id} cache purge failed: #{e.}") { request_id: nil, error: e. } end end |
#purge_file_cache_status ⇒ Object
738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 |
# File 'app/models/image.rb', line 738 def purge_file_cache_status return unless purge_cache_request_id return purge_cache_request_id if purge_cache_request_id == 'Completed' # Use ImageKitFactory helper method res = ImageKitFactory.get_purge_status(purge_cache_request_id) if res.nil? # Request ID is invalid or expired - clear it and return appropriate message update_column(:purge_cache_request_id, nil) return 'Expired/Invalid' end status = res&.status || 'unknown' # ImageKit 4.0 returns object with status attribute # Once the status is Completed the status is final, we can override the request id and return it # right away in the future update_column(:purge_cache_request_id, status) if status == 'Completed' status end |
#related_image_links ⇒ ActiveRecord::Relation<RelatedImage>
Related images (upscaled versions, duplicates, variants)
Forward relationships: this image is the original/source
222 |
# File 'app/models/image.rb', line 222 has_many :related_image_links, class_name: 'RelatedImage', dependent: :destroy, inverse_of: :image |
#related_images ⇒ ActiveRecord::Relation<RelatedImage>
223 |
# File 'app/models/image.rb', line 223 has_many :related_images, through: :related_image_links, source: :related_image |
#reviews_io_images ⇒ ActiveRecord::Relation<ReviewsIoImage>
217 |
# File 'app/models/image.rb', line 217 has_many :reviews_io_images, dependent: :destroy |
#reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
218 |
# File 'app/models/image.rb', line 218 has_many :reviews_ios, through: :reviews_io_images |
#should_generate_new_friendly_id? ⇒ Boolean
691 692 693 694 695 |
# File 'app/models/image.rb', line 691 def should_generate_new_friendly_id? return false if persisted? && !force_new_slug.to_b super end |
#should_rename_on_imagekit? ⇒ Boolean
545 546 547 548 |
# File 'app/models/image.rb', line 545 def should_rename_on_imagekit? # When force_new_slug is used, automatically rename the file on ImageKit too force_new_slug.to_b && asset.present? && slug_previously_changed? end |
#source_image_links ⇒ ActiveRecord::Relation<RelatedImage>
Reverse relationships: this image is derived from another
225 |
# File 'app/models/image.rb', line 225 has_many :source_image_links, class_name: 'RelatedImage', foreign_key: :related_image_id, dependent: :destroy, inverse_of: :related_image |
#source_images ⇒ ActiveRecord::Relation<SourceImage>
226 |
# File 'app/models/image.rb', line 226 has_many :source_images, through: :source_image_links, source: :image |
#tags=(value) ⇒ Object
Override to enforce exclusive tag uniqueness before persisting.
Batch-checks all exclusive tags in a single query to avoid N+1.
464 465 466 467 468 |
# File 'app/models/image.rb', line 464 def (value) tag_names = parse_tag_value(value) (tag_names) super end |
#upload_new_image ⇒ Object
Before save call to do the actual uploading to imagekit
new_image can be an instance of ActionDispatch::Http::UploadedFile from an upload
or a plain file object
636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 |
# File 'app/models/image.rb', line 636 def upload_new_image return unless new_image begin new_image = self.new_image # The new_image goes out of scope in this block so we re-establish it # purge_cache # Always good idea to purge the cache before uploading if new_image.respond_to?(:path) file_path = new_image.path original_filename = new_image.original_filename if new_image.respond_to?(:original_filename) self. = Image.(file_path, original_filename) elsif new_image.is_a?(String) if Image.is_url?(new_image) image_url = new_image require 'down' new_image = Down::Http.download(image_url) { |client| client.timeout(read: 120) } file_path = new_image.path self. = Image.(file_path) # Our string can be a file path elsif File.exist?(new_image) file_path = new_image self. = Image.(file_path) new_image = File.open(file_path) end end # By convention with image kit, we strip extensions from file name, because of the dynamic nature of imagekit # And its ability to serve different format using parameter or adaptive format # Some legacy systems out there still read the file extension file_name = new_filename.presence || ik_file_name res = Image.upload_file_to_ik(new_image, file_name:, tags: ) if res[:error] errors.add(:new_image, "There was a problem uploading your image #{res[:error]}") else self.asset = res[:response] extract_info_from_asset end rescue StandardError => e errors.add(:new_image, "There was a problem uploading your image #{e}") ensure new_image.flush if new_image.respond_to?(:flush) new_image.fsync if new_image.respond_to?(:fsync) new_image.close if new_image.respond_to?(:close) end end |
#upscale_eligible? ⇒ Boolean
Check if this image is eligible for AI upscaling
Eligible if: active, has dimensions, not already upscaled, within ImageKit 16MP hard limit
863 864 865 866 867 868 869 870 871 872 873 874 |
# File 'app/models/image.rb', line 863 def upscale_eligible? return false if inactive? return false unless .present? && .present? return false if upscaled? # Check ImageKit's input limit (must be < 16MP) total_pixels = * return false if total_pixels >= UPSCALE_MAX_INPUT_PIXELS # Must have ImageKit asset ik_path.present? end |
#upscale_preview_url(format: nil, quality: 95) ⇒ String
Generate a preview URL with ImageKit's e-upscale transformation
The format parameter ensures WYSIWYG preview (overrides auto-format)
900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 |
# File 'app/models/image.rb', line 900 def upscale_preview_url(format: nil, quality: 95) format ||= default_upscale_format transformations = [] # Add upscale transformation transformations << { raw: 'e-upscale' } # Explicitly set format to override auto-format negotiation # This ensures preview matches what will be stored format_transform = { f: format.to_s } format_transform[:q] = quality.to_i if %w[jpeg jpg webp].include?(format.to_s.downcase) transformations << format_transform ik_url(transformations: transformations) end |
#upscaled? ⇒ Boolean
Check if this image has already been upscaled
880 881 882 |
# File 'app/models/image.rb', line 880 def upscaled? ( & ALL_UPSCALE_TAGS).any? end |
#video_posters ⇒ ActiveRecord::Relation<Video>
214 |
# File 'app/models/image.rb', line 214 has_many :video_posters, class_name: 'Video', foreign_key: :poster_image_id, dependent: :nullify, inverse_of: :poster_image |