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 :enum default("pending")
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
- LETTER_ASPECT_RATIO =
US Letter portrait page ratio (8.5" × 11", width / height ≈ 0.773) — the
target shape for document/publication cover thumbnails (see the www
.ratio-letterCSS utility and the CRM cover-ratio-mismatch filter).
"Generate Cover" renders a PDF's letter-size first page at this ratio. (8.5 / 11.0)
- LETTER_RATIO_TOLERANCE =
Half-width of the band around LETTER_ASPECT_RATIO still treated as "letter".
0.06- UPSCALE_TAG =
Tags applied to images after AI upscaling
'imagekit-upscaled'.freeze
- TOPAZ_UPSCALE_TAG =
Topaz upscale tag.
'topaz-upscaled'.freeze
- ALL_UPSCALE_TAGS =
Recognised 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::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
Constants included from Schedulable
Schedulable::SIMPLE_FORM_OPTIONS
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>
-
#upscale_proposals ⇒ ActiveRecord::Relation<UpscaleProposal>
Pending AI-upscale staging records; useless without their source image.
- #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
-
.letter_ratio ⇒ ActiveRecord::Relation<Image>
A relation of Images that are letter ratio.
-
.not_letter_ratio ⇒ ActiveRecord::Relation<Image>
A relation of Images that are not letter ratio.
- .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_known_dimensions ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with known dimensions.
-
.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.
- #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
-
#externally_referenced? ⇒ Boolean
Non-owned references a purge must never break: a product's primary image (restrict_with_error) or an article preview / video poster (nullify).
- #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, #aspect_ratio_label, #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, #invalidate!, invalidated, #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, valid, 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 Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#crop_h ⇒ Object
Returns the value of attribute crop_h.
232 233 234 |
# File 'app/models/image.rb', line 232 def crop_h @crop_h end |
#crop_w ⇒ Object
Returns the value of attribute crop_w.
232 233 234 |
# File 'app/models/image.rb', line 232 def crop_w @crop_w end |
#crop_x ⇒ Object
Returns the value of attribute crop_x.
232 233 234 |
# File 'app/models/image.rb', line 232 def crop_x @crop_x end |
#crop_y ⇒ Object
Returns the value of attribute crop_y.
232 233 234 |
# File 'app/models/image.rb', line 232 def crop_y @crop_y end |
#make_primary_item_image ⇒ Object
Returns the value of attribute make_primary_item_image.
232 233 234 |
# File 'app/models/image.rb', line 232 def make_primary_item_image @make_primary_item_image end |
#new_filename ⇒ Object
Returns the value of attribute new_filename.
232 233 234 |
# File 'app/models/image.rb', line 232 def new_filename @new_filename end |
#new_image ⇒ Object
Returns the value of attribute new_image.
232 233 234 |
# File 'app/models/image.rb', line 232 def new_image @new_image end |
#preserve_original_file ⇒ Object
Returns the value of attribute preserve_original_file.
232 233 234 |
# File 'app/models/image.rb', line 232 def preserve_original_file @preserve_original_file end |
#skip_imagekit_deletion ⇒ Object
Returns the value of attribute skip_imagekit_deletion.
232 233 234 |
# File 'app/models/image.rb', line 232 def skip_imagekit_deletion @skip_imagekit_deletion end |
#skip_notify ⇒ Object
Returns the value of attribute skip_notify.
232 233 234 |
# File 'app/models/image.rb', line 232 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
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 |
# File 'app/models/image.rb', line 395 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
489 490 491 492 |
# File 'app/models/image.rb', line 489 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.
640 641 642 643 644 645 |
# File 'app/models/image.rb', line 640 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
268 |
# File 'app/models/image.rb', line 268 scope :by_merged_from_id, ->(id) { where('merged_from_ids @> ARRAY[?]::integer[]', id.to_i) } |
.embeddable_content_types ⇒ Object
Embeddable configuration
814 815 816 |
# File 'app/models/image.rb', line 814 def self. [:primary] end |
.embedding_status ⇒ ActiveRecord::Relation<Image>
A relation of Images that are embedding status. Active Record Scope
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 |
# File 'app/models/image.rb', line 284 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
459 460 461 462 463 464 465 466 467 468 |
# File 'app/models/image.rb', line 459 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.
1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 |
# File 'app/models/image.rb', line 1052 def self.find_phash_duplicates_of(target_fingerprint, exclude_id: nil, threshold: 15, limit: 20) return [] if target_fingerprint.blank? # 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).
1108 1109 1110 1111 1112 1113 1114 |
# File 'app/models/image.rb', line 1108 def self.fingerprint_to_hex(fingerprint) return nil if fingerprint.blank? # 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
263 |
# File 'app/models/image.rb', line 263 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.
1094 1095 1096 1097 1098 1099 1100 |
# File 'app/models/image.rb', line 1094 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
440 441 442 443 444 445 446 447 448 449 450 451 |
# File 'app/models/image.rb', line 440 scope :hybrid_search, ->(query, limit: 500) { return none if query.blank? ai_ids = ai_search(query, limit: limit).ids keyword_ids = begin keyword_search(query).limit(limit).ids rescue PgSearch::EmptyQueryError [] end rrf_ranked_relation(ai_ids, keyword_ids, limit: limit) } |
.is_url?(string) ⇒ Boolean
647 648 649 650 |
# File 'app/models/image.rb', line 647 def self.is_url?(string) uri = Addressable::URI.parse(string) %w[http https].include?(uri.scheme) end |
.letter_ratio ⇒ ActiveRecord::Relation<Image>
A relation of Images that are letter ratio. Active Record Scope
142 143 144 145 146 147 148 |
# File 'app/models/image.rb', line 142 scope :letter_ratio, -> { with_known_dimensions.where( 'attachment_width::numeric / attachment_height BETWEEN ? AND ?', LETTER_ASPECT_RATIO - LETTER_RATIO_TOLERANCE, LETTER_ASPECT_RATIO + LETTER_RATIO_TOLERANCE ) } |
.not_letter_ratio ⇒ ActiveRecord::Relation<Image>
A relation of Images that are not letter ratio. Active Record Scope
150 151 152 153 154 155 156 |
# File 'app/models/image.rb', line 150 scope :not_letter_ratio, -> { with_known_dimensions.where( 'attachment_width::numeric / attachment_height < ? OR attachment_width::numeric / attachment_height > ?', LETTER_ASPECT_RATIO - LETTER_RATIO_TOLERANCE, LETTER_ASPECT_RATIO + LETTER_RATIO_TOLERANCE ) } |
.ransackable_scopes(_auth_object = nil) ⇒ Object
453 454 455 |
# File 'app/models/image.rb', line 453 def self.ransackable_scopes(_auth_object = nil) super + %i[embedding_status ai_search hybrid_search upscale_eligible] end |
.suggested_sources_for_select ⇒ Object
508 509 510 |
# File 'app/models/image.rb', line 508 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
588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 |
# File 'app/models/image.rb', line 588 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
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'app/models/image.rb', line 302 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
264 |
# File 'app/models/image.rb', line 264 scope :video_posters, -> { tagged_with('video-poster') } |
.with_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with embedding. Active Record Scope
275 276 277 278 279 280 |
# File 'app/models/image.rb', line 275 scope :with_embedding, -> { 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_known_dimensions ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with known dimensions. Active Record Scope
140 |
# File 'app/models/image.rb', line 140 scope :with_known_dimensions, -> { where.not(attachment_width: nil).where(arel_table[:attachment_height].gt(0)) } |
.with_phash ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with phash. Active Record Scope
271 |
# File 'app/models/image.rb', line 271 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
357 358 359 360 361 362 |
# File 'app/models/image.rb', line 357 scope :with_unified_embedding, -> { 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
273 |
# File 'app/models/image.rb', line 273 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
281 |
# File 'app/models/image.rb', line 281 scope :without_embedding, -> { where.not(id: .select(:id)) } |
.without_phash ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without phash. Active Record Scope
272 |
# File 'app/models/image.rb', line 272 scope :without_phash, -> { where(fingerprint: nil) } |
.without_unified_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without unified embedding. Active Record Scope
363 |
# File 'app/models/image.rb', line 363 scope :without_unified_embedding, -> { where.not(id: .select(:id)) } |
.without_vision ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without vision. Active Record Scope
274 |
# File 'app/models/image.rb', line 274 scope :without_vision, -> { where(ai_visual_description: [nil, '']) } |
Instance Method Details
#add_tag(tag_name) ⇒ Object
Override to enforce exclusive tag uniqueness before persisting.
495 496 497 498 |
# File 'app/models/image.rb', line 495 def add_tag(tag_name) validate_exclusive_tag!(tag_name) super end |
#ai_search ⇒ ActiveRecord::Relation
AI semantic search scope using primary text embeddings.
Accepts query (String), limit: (Integer, default 500), and
max_distance: (Float, optional cosine distance threshold; 0=identical,
2=opposite, nil = no threshold). Use 0.7 for moderate filtering, 0.5 for
strict filtering. Uses the ImageEmbedding partition model for proper joins
with the neighbor gem.
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 |
# File 'app/models/image.rb', line 395 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
512 513 514 |
# File 'app/models/image.rb', line 512 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)
886 887 888 |
# File 'app/models/image.rb', line 886 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
989 990 991 992 993 994 995 996 |
# File 'app/models/image.rb', line 989 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.
823 824 825 826 827 828 829 830 831 |
# File 'app/models/image.rb', line 823 def (_content_type = :primary) [ , , , , ].flatten.compact.join("\n\n") end |
#deep_dup ⇒ Object
470 471 472 473 474 475 476 477 |
# File 'app/models/image.rb', line 470 def deep_dup deep_clone( include: %i[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
964 965 966 967 968 969 970 971 972 973 |
# File 'app/models/image.rb', line 964 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)
980 981 982 |
# File 'app/models/image.rb', line 980 def default_upscale_quality 95 end |
#delete_from_imagekit ⇒ Object
delete from imagekit server
736 737 738 739 740 741 742 743 744 745 746 747 748 749 |
# File 'app/models/image.rb', line 736 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
806 807 808 809 810 811 |
# File 'app/models/image.rb', line 806 def determine_mime_type return if .present? return if .blank? MIME::Types.type_for(".#{}").first&.to_s end |
#digital_assets_duplicates ⇒ ActiveRecord::Relation<DigitalAssetsDuplicate>
242 |
# File 'app/models/image.rb', line 242 has_many :digital_assets_duplicates, dependent: :destroy |
#embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
247 |
# File 'app/models/image.rb', line 247 has_many :embedded_assets, as: :asset, dependent: :destroy |
#externally_referenced? ⇒ Boolean
Non-owned references a purge must never break: a product's primary image
(restrict_with_error) or an article preview / video poster (nullify). Owned
dependents (embeddings, profiles, related links, …) cascade and don't count.
InvalidDigitalAssetPurgeWorker uses this to retain still-referenced images
for manual recovery instead of deleting/detaching them.
485 486 487 |
# File 'app/models/image.rb', line 485 def externally_referenced? primary_item_images.exists? || preview_image_articles.exists? || video_posters.exists? end |
#extract_info_from_asset ⇒ Object
521 522 523 524 525 526 527 528 529 530 531 532 533 |
# File 'app/models/image.rb', line 521 def extract_info_from_asset return if asset.blank? 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.
1034 1035 1036 1037 1038 |
# File 'app/models/image.rb', line 1034 def find_phash_duplicates(threshold: 15, limit: 20) return [] if fingerprint.blank? 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.
1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 |
# File 'app/models/image.rb', line 1005 def find_visually_similar(limit: 5) unified = (:unified) return [] if unified&..blank? 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) .filter_map(&:embeddable) end |
#fingerprint_hex ⇒ String?
Display the fingerprint as hex string (for UI/debugging)
1118 1119 1120 |
# File 'app/models/image.rb', line 1118 def fingerprint_hex self.class.fingerprint_to_hex(fingerprint) end |
#ik_path ⇒ Object
516 517 518 519 |
# File 'app/models/image.rb', line 516 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
535 536 537 538 539 540 541 |
# File 'app/models/image.rb', line 535 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'
206 207 208 209 210 |
# File 'app/models/image.rb', line 206 has_many :image_embeddings, class_name: 'ContentEmbedding::ImageEmbedding', foreign_key: :embeddable_id, dependent: :destroy, inverse_of: :embeddable |
#image_profiles ⇒ ActiveRecord::Relation<ImageProfile>
244 |
# File 'app/models/image.rb', line 244 has_many :image_profiles, dependent: :destroy |
#imagekit_tags ⇒ Object
718 719 720 721 722 723 724 725 726 727 |
# File 'app/models/image.rb', line 718 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.
654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 |
# File 'app/models/image.rb', line 654 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
795 796 797 798 799 800 801 802 803 804 |
# File 'app/models/image.rb', line 795 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
924 925 926 927 928 929 |
# File 'app/models/image.rb', line 924 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)
546 547 548 549 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 |
# File 'app/models/image.rb', line 546 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>
243 |
# File 'app/models/image.rb', line 243 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>
246 |
# File 'app/models/image.rb', line 246 has_many :primary_item_images, class_name: 'Item', foreign_key: :primary_image_id, dependent: :restrict_with_error, inverse_of: :primary_image |
#purge_cache ⇒ Object
751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 |
# File 'app/models/image.rb', line 751 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
776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 |
# File 'app/models/image.rb', line 776 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
255 |
# File 'app/models/image.rb', line 255 has_many :related_image_links, class_name: 'RelatedImage', dependent: :destroy, inverse_of: :image |
#related_images ⇒ ActiveRecord::Relation<RelatedImage>
256 |
# File 'app/models/image.rb', line 256 has_many :related_images, through: :related_image_links, source: :related_image |
#reviews_io_images ⇒ ActiveRecord::Relation<ReviewsIoImage>
248 |
# File 'app/models/image.rb', line 248 has_many :reviews_io_images, dependent: :destroy |
#reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
249 |
# File 'app/models/image.rb', line 249 has_many :reviews_ios, through: :reviews_io_images |
#should_generate_new_friendly_id? ⇒ Boolean
729 730 731 732 733 |
# File 'app/models/image.rb', line 729 def should_generate_new_friendly_id? return false if persisted? && !force_new_slug.to_b super end |
#should_rename_on_imagekit? ⇒ Boolean
583 584 585 586 |
# File 'app/models/image.rb', line 583 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
258 |
# File 'app/models/image.rb', line 258 has_many :source_image_links, class_name: 'RelatedImage', foreign_key: :related_image_id, dependent: :destroy, inverse_of: :related_image |
#source_images ⇒ ActiveRecord::Relation<SourceImage>
259 |
# File 'app/models/image.rb', line 259 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.
502 503 504 505 506 |
# File 'app/models/image.rb', line 502 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
674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 |
# File 'app/models/image.rb', line 674 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
901 902 903 904 905 906 907 908 909 910 911 912 |
# File 'app/models/image.rb', line 901 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)
938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 |
# File 'app/models/image.rb', line 938 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) # JPEG has no alpha channel: without an explicit background ImageKit fills # transparent PNG regions with black. Force white so a transparent original # upscales onto white. PNG/WebP keep their alpha, so they need no background. format_transform[:bg] = 'FFFFFF' if %w[jpeg jpg].include?(format.to_s.downcase) transformations << format_transform ik_url(transformations: transformations) end |
#upscale_proposals ⇒ ActiveRecord::Relation<UpscaleProposal>
Pending AI-upscale staging records; useless without their source image.
251 |
# File 'app/models/image.rb', line 251 has_many :upscale_proposals, dependent: :destroy, inverse_of: :image |
#upscaled? ⇒ Boolean
Check if this image has already been upscaled
918 919 920 |
# File 'app/models/image.rb', line 918 def upscaled? .intersect?(ALL_UPSCALE_TAGS) end |
#video_posters ⇒ ActiveRecord::Relation<Video>
245 |
# File 'app/models/image.rb', line 245 has_many :video_posters, class_name: 'Video', foreign_key: :poster_image_id, dependent: :nullify, inverse_of: :poster_image |