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
- AVATAR_THUMBNAIL_DIMENSIONS =
Thumbnail size for review/testimonial avatars. 96px is 2x the largest
render size (48px card, 44px modal), so it stays crisp on retina without
the 400x400 STANDARD_THUMBNAIL_SIZE default (~10x the bytes). '96x96>'- 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 Models::Schedulable
Models::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>
Duplicate-detection records for this asset.
-
#embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
Embedded asset records referencing this image.
-
#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>
Image profiles attaching this image to items.
-
#preview_image_articles ⇒ ActiveRecord::Relation<Article>
Articles using this image as their preview image.
-
#primary_item_images ⇒ ActiveRecord::Relation<Item>
Items using this image as their primary image.
-
#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>
Derived images (upscaled versions, duplicates, variants) linked from this image.
-
#reviews_io_images ⇒ ActiveRecord::Relation<ReviewsIoImage>
Reviews.io image links for this image.
-
#reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
Reviews.io reviews linked through #reviews_io_images.
-
#source_image_links ⇒ ActiveRecord::Relation<RelatedImage>
Reverse relationships: this image is derived from another.
-
#source_images ⇒ ActiveRecord::Relation<SourceImage>
Source images this image was derived from.
-
#upscale_proposals ⇒ ActiveRecord::Relation<UpscaleProposal>
Pending AI-upscale staging records; useless without their source image.
-
#video_posters ⇒ ActiveRecord::Relation<Video>
Videos using this image as their poster.
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: []) ⇒ Array<String>
All tags on images, delegating to the taggable concern (STI-aware).
-
.attachment_format_from_file_path(file_path, original_file_name = nil) ⇒ String
This will return the file type, e.g.
-
.avatar_thumbnail_urls(reviews) ⇒ Hash{Integer => String}
Batch-loads review-avatar thumbnail URLs for a set of reviews in one query.
-
.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) ⇒ Image?
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
Whether the given string is an HTTP(S) URL.
-
.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) ⇒ Array<Symbol>
Scopes Ransack is allowed to call from the image library search form.
-
.suggested_sources_for_select ⇒ Array<String>
Distinct existing
sourcevalues for use in select dropdowns. -
.upload_file_to_ik(file, file_name: nil, tags: [], folder: 'img/') ⇒ Hash?
Uploads a file to ImageKit.
-
.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) ⇒ void
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 ⇒ Array<ProductCategory>
Every product category applicable to this image, including descendants.
-
#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) ⇒ String
Returns content for text embedding generation.
-
#deep_dup ⇒ Image
Deep-clones the image with its associations, titling the copy
"<title> copy". -
#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 ⇒ String?
MIME type of the attachment, derived from the stored format when not set.
-
#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 ⇒ Image
Copies dimensions, format, MIME type, name, and size from the ImageKit
assetpayload. -
#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).
-
#fingerprint_hex ⇒ String?
Display the fingerprint as hex string (for UI/debugging).
-
#ik_path ⇒ String?
ImageKit file path of the stored asset.
-
#ik_rename ⇒ void
Renames the file on ImageKit to the current friendly-id file name.
-
#imagekit_tags ⇒ Array<String>
Full tag list applied to the ImageKit asset (id, reference number, tags, product slugs).
-
#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) ⇒ Integer?
Sets this image as the primary image on the given (or all applicable) items.
-
#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 ⇒ Hash?
Purges the CDN cache for this image's ImageKit URL (wildcarded across formats).
-
#purge_file_cache_status ⇒ String?
Current status of the last ImageKit purge request.
-
#should_generate_new_friendly_id? ⇒ Boolean
Whether FriendlyId should regenerate the slug (only when forced on a persisted record).
-
#should_rename_on_imagekit? ⇒ Boolean
Whether the file should be renamed on ImageKit after a forced slug change.
-
#tags=(value) ⇒ void
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
ranked_ids, rrf_ranked_relation
Methods included from Models::Embeddable
#embeddable_locales, #embedding_content_hash, #embedding_current?, #embedding_eligible?, 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, with_shape_lock
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, by_product_line_path_excluding, 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, 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::EdgeCachePurgeable
#edge_cache_urls, #enqueue_edge_cache_purge, #purge_edge_cache
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::Schedulable
Methods included from Models::AfterCommittable
Methods included from Models::EventPublishable
Instance Attribute Details
#crop_h ⇒ Object
Returns the value of attribute crop_h.
236 237 238 |
# File 'app/models/image.rb', line 236 def crop_h @crop_h end |
#crop_w ⇒ Object
Returns the value of attribute crop_w.
236 237 238 |
# File 'app/models/image.rb', line 236 def crop_w @crop_w end |
#crop_x ⇒ Object
Returns the value of attribute crop_x.
236 237 238 |
# File 'app/models/image.rb', line 236 def crop_x @crop_x end |
#crop_y ⇒ Object
Returns the value of attribute crop_y.
236 237 238 |
# File 'app/models/image.rb', line 236 def crop_y @crop_y end |
#make_primary_item_image ⇒ Object
Returns the value of attribute make_primary_item_image.
236 237 238 |
# File 'app/models/image.rb', line 236 def make_primary_item_image @make_primary_item_image end |
#new_filename ⇒ Object
Returns the value of attribute new_filename.
236 237 238 |
# File 'app/models/image.rb', line 236 def new_filename @new_filename end |
#new_image ⇒ Object
Returns the value of attribute new_image.
236 237 238 |
# File 'app/models/image.rb', line 236 def new_image @new_image end |
#preserve_original_file ⇒ Object
Returns the value of attribute preserve_original_file.
236 237 238 |
# File 'app/models/image.rb', line 236 def preserve_original_file @preserve_original_file end |
#skip_imagekit_deletion ⇒ Object
Returns the value of attribute skip_imagekit_deletion.
236 237 238 |
# File 'app/models/image.rb', line 236 def skip_imagekit_deletion @skip_imagekit_deletion end |
#skip_notify ⇒ Object
Returns the value of attribute skip_notify.
236 237 238 |
# File 'app/models/image.rb', line 236 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
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 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 |
# File 'app/models/image.rb', line 410 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: []) ⇒ Array<String>
All tags on images, delegating to the taggable concern (STI-aware).
527 528 529 530 |
# File 'app/models/image.rb', line 527 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) ⇒ String
This will return the file type, e.g. jpeg, gif, png, etc.
703 704 705 706 707 708 |
# File 'app/models/image.rb', line 703 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 |
.avatar_thumbnail_urls(reviews) ⇒ Hash{Integer => String}
Batch-loads review-avatar thumbnail URLs for a set of reviews in one query.
Shared by the storefront review slider component and the reviews controller
so the avatar dimensions (AVATAR_THUMBNAIL_DIMENSIONS) stay in one place.
496 497 498 499 500 501 |
# File 'app/models/image.rb', line 496 def self.avatar_thumbnail_urls(reviews) ids = reviews.filter_map(&:avatar_image_id).uniq return {} if ids.empty? where(id: ids).to_h { |img| [img.id, img.thumbnail_url(dimensions: AVATAR_THUMBNAIL_DIMENSIONS)] } end |
.by_merged_from_id ⇒ ActiveRecord::Relation<Image>
A relation of Images that are by merged from id. Active Record Scope
282 |
# File 'app/models/image.rb', line 282 scope :by_merged_from_id, ->(id) { where('merged_from_ids @> ARRAY[?]::integer[]', id.to_i) } |
.embeddable_content_types ⇒ Object
Embeddable configuration
908 909 910 |
# File 'app/models/image.rb', line 908 def self. [:primary] end |
.embedding_status ⇒ ActiveRecord::Relation<Image>
A relation of Images that are embedding status. Active Record Scope
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 |
# File 'app/models/image.rb', line 298 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) ⇒ Image?
Find an image by ID, slug, or legacy merged ID
Used by helpers to support hardcoded image IDs that may have been merged
479 480 481 482 483 484 485 486 487 488 |
# File 'app/models/image.rb', line 479 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.
1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 |
# File 'app/models/image.rb', line 1148 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).
1204 1205 1206 1207 1208 1209 1210 |
# File 'app/models/image.rb', line 1204 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
277 |
# File 'app/models/image.rb', line 277 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.
1190 1191 1192 1193 1194 1195 1196 |
# File 'app/models/image.rb', line 1190 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
455 456 457 458 459 460 461 462 463 464 465 466 |
# File 'app/models/image.rb', line 455 scope :hybrid_search, ->(query, limit: 500) { return none if query.blank? ai_ids = ranked_ids(ai_search(query, limit: limit)) keyword_ids = begin ranked_ids(keyword_search(query).limit(limit)) rescue PgSearch::EmptyQueryError [] end rrf_ranked_relation(ai_ids, keyword_ids, limit: limit) } |
.is_url?(string) ⇒ Boolean
Whether the given string is an HTTP(S) URL.
713 714 715 716 |
# File 'app/models/image.rb', line 713 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
146 147 148 149 150 151 152 |
# File 'app/models/image.rb', line 146 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
154 155 156 157 158 159 160 |
# File 'app/models/image.rb', line 154 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) ⇒ Array<Symbol>
Scopes Ransack is allowed to call from the image library search form.
471 472 473 |
# File 'app/models/image.rb', line 471 def self.ransackable_scopes(_auth_object = nil) super + %i[embedding_status ai_search hybrid_search upscale_eligible] end |
.suggested_sources_for_select ⇒ Array<String>
Distinct existing source values for use in select dropdowns.
552 553 554 |
# File 'app/models/image.rb', line 552 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/') ⇒ Hash?
Uploads a file to ImageKit.
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 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 |
# File 'app/models/image.rb', line 648 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
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 355 356 357 358 359 360 361 362 363 364 365 366 367 368 |
# File 'app/models/image.rb', line 316 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
278 |
# File 'app/models/image.rb', line 278 scope :video_posters, -> { tagged_with('video-poster') } |
.with_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are with embedding. Active Record Scope
289 290 291 292 293 294 |
# File 'app/models/image.rb', line 289 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
144 |
# File 'app/models/image.rb', line 144 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
285 |
# File 'app/models/image.rb', line 285 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
371 372 373 374 375 376 |
# File 'app/models/image.rb', line 371 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
287 |
# File 'app/models/image.rb', line 287 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
295 |
# File 'app/models/image.rb', line 295 scope :without_embedding, -> { where.not(id: .select(:id)) } |
.without_phash ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without phash. Active Record Scope
286 |
# File 'app/models/image.rb', line 286 scope :without_phash, -> { where(fingerprint: nil) } |
.without_unified_embedding ⇒ ActiveRecord::Relation<Image>
A relation of Images that are without unified embedding. Active Record Scope
377 |
# File 'app/models/image.rb', line 377 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
288 |
# File 'app/models/image.rb', line 288 scope :without_vision, -> { where(ai_visual_description: [nil, '']) } |
Instance Method Details
#add_tag(tag_name) ⇒ void
This method returns an undefined value.
Override to enforce exclusive tag uniqueness before persisting.
535 536 537 538 |
# File 'app/models/image.rb', line 535 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.
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 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 |
# File 'app/models/image.rb', line 410 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 ⇒ Array<ProductCategory>
Every product category applicable to this image, including descendants.
558 559 560 |
# File 'app/models/image.rb', line 558 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)
992 993 994 |
# File 'app/models/image.rb', line 992 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
1088 1089 1090 1091 1092 1093 1094 1095 |
# File 'app/models/image.rb', line 1088 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) ⇒ String
Returns nil if Vision analysis is not complete.
This enforces the dependency chain: Vision → Text embedding
Content used for embedding - includes vision description + metadata.
Returns content for text embedding generation.
918 919 920 921 922 923 924 925 926 |
# File 'app/models/image.rb', line 918 def (_content_type = :primary) [ , , , , ].flatten.compact.join("\n\n") end |
#deep_dup ⇒ Image
Deep-clones the image with its associations, titling the copy "<title> copy".
505 506 507 508 509 510 511 512 |
# File 'app/models/image.rb', line 505 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
1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 |
# File 'app/models/image.rb', line 1065 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)
1080 1081 1082 |
# File 'app/models/image.rb', line 1080 def default_upscale_quality 95 end |
#delete_from_imagekit ⇒ Object
delete from imagekit server
812 813 814 815 816 817 818 819 820 821 822 823 824 825 |
# File 'app/models/image.rb', line 812 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 ⇒ String?
MIME type of the attachment, derived from the stored format when not set.
900 901 902 903 904 905 |
# File 'app/models/image.rb', line 900 def determine_mime_type return if .present? return if .blank? MIME::Types.type_for(".#{}").first&.to_s end |
#digital_assets_duplicates ⇒ ActiveRecord::Relation<DigitalAssetsDuplicate>
Duplicate-detection records for this asset.
247 |
# File 'app/models/image.rb', line 247 has_many :digital_assets_duplicates, dependent: :destroy |
#embedded_assets ⇒ ActiveRecord::Relation<EmbeddedAsset>
Embedded asset records referencing this image.
257 |
# File 'app/models/image.rb', line 257 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.
520 521 522 |
# File 'app/models/image.rb', line 520 def externally_referenced? primary_item_images.exists? || preview_image_articles.exists? || video_posters.exists? end |
#extract_info_from_asset ⇒ Image
Copies dimensions, format, MIME type, name, and size from the ImageKit asset payload.
571 572 573 574 575 576 577 578 579 580 581 582 583 |
# File 'app/models/image.rb', line 571 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.
1131 1132 1133 1134 1135 |
# File 'app/models/image.rb', line 1131 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>
This finds SEMANTICALLY similar images (same concept/subject).
For true duplicate detection, use #find_phash_duplicates instead.
Find visually similar images using unified embeddings (Gemini Embedding 2)
1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 |
# File 'app/models/image.rb', line 1103 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)
1214 1215 1216 |
# File 'app/models/image.rb', line 1214 def fingerprint_hex self.class.fingerprint_to_hex(fingerprint) end |
#ik_path ⇒ String?
ImageKit file path of the stored asset.
564 565 566 567 |
# File 'app/models/image.rb', line 564 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 ⇒ void
This method returns an undefined value.
Renames the file on ImageKit to the current friendly-id file name.
587 588 589 590 591 592 593 |
# File 'app/models/image.rb', line 587 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'
210 211 212 213 214 |
# File 'app/models/image.rb', line 210 has_many :image_embeddings, class_name: 'ContentEmbedding::ImageEmbedding', foreign_key: :embeddable_id, dependent: :destroy, inverse_of: :embeddable |
#image_profiles ⇒ ActiveRecord::Relation<ImageProfile>
Image profiles attaching this image to items.
251 |
# File 'app/models/image.rb', line 251 has_many :image_profiles, dependent: :destroy |
#imagekit_tags ⇒ Array<String>
Full tag list applied to the ImageKit asset (id, reference number, tags, product slugs).
792 793 794 795 796 797 798 799 800 801 |
# File 'app/models/image.rb', line 792 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.
720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 |
# File 'app/models/image.rb', line 720 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) ⇒ Integer?
Sets this image as the primary image on the given (or all applicable) items.
887 888 889 890 891 892 893 894 895 896 |
# File 'app/models/image.rb', line 887 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
1027 1028 1029 1030 1031 1032 |
# File 'app/models/image.rb', line 1027 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)
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 |
# File 'app/models/image.rb', line 598 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>
Articles using this image as their preview image.
249 |
# File 'app/models/image.rb', line 249 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>
Items using this image as their primary image.
255 |
# File 'app/models/image.rb', line 255 has_many :primary_item_images, class_name: 'Item', foreign_key: :primary_image_id, dependent: :restrict_with_error, inverse_of: :primary_image |
#purge_cache ⇒ Hash?
Purges the CDN cache for this image's ImageKit URL (wildcarded across formats).
829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 |
# File 'app/models/image.rb', line 829 def purge_cache # ik_raw_url is the bare host when this image has no ImageKit asset (a # legacy `url` record, or one whose upload failed), and the wildcard below # would then turn it into `https://ik.warmlyyours.com/*` — a purge of the # ENTIRE account. Nothing of ours is on the CDN, so there is nothing to do. return if ik_path.blank? 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 # Swallowed so a purge failure never breaks the save/destroy that asked # for it — but a log line alone means a stale CDN copy nobody hears about, # which is the failure mode this whole purge path exists to remove. Rails.logger.error("Image #{id} cache purge failed: #{e.}") ErrorReporting.error(e, { image_id: id, ik_file_name: }) { request_id: nil, error: e. } end end |
#purge_file_cache_status ⇒ String?
Current status of the last ImageKit purge request.
865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 |
# File 'app/models/image.rb', line 865 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
267 |
# File 'app/models/image.rb', line 267 has_many :related_image_links, class_name: 'RelatedImage', dependent: :destroy, inverse_of: :image |
#related_images ⇒ ActiveRecord::Relation<RelatedImage>
Derived images (upscaled versions, duplicates, variants) linked from this image.
269 |
# File 'app/models/image.rb', line 269 has_many :related_images, through: :related_image_links, source: :related_image |
#reviews_io_images ⇒ ActiveRecord::Relation<ReviewsIoImage>
Reviews.io image links for this image.
259 |
# File 'app/models/image.rb', line 259 has_many :reviews_io_images, dependent: :destroy |
#reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
Reviews.io reviews linked through #reviews_io_images.
261 |
# File 'app/models/image.rb', line 261 has_many :reviews_ios, through: :reviews_io_images |
#should_generate_new_friendly_id? ⇒ Boolean
Whether FriendlyId should regenerate the slug (only when forced on a persisted record).
805 806 807 808 809 |
# File 'app/models/image.rb', line 805 def should_generate_new_friendly_id? return false if persisted? && !force_new_slug.to_b super end |
#should_rename_on_imagekit? ⇒ Boolean
Whether the file should be renamed on ImageKit after a forced slug change.
637 638 639 640 |
# File 'app/models/image.rb', line 637 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
271 |
# File 'app/models/image.rb', line 271 has_many :source_image_links, class_name: 'RelatedImage', foreign_key: :related_image_id, dependent: :destroy, inverse_of: :related_image |
#source_images ⇒ ActiveRecord::Relation<SourceImage>
Source images this image was derived from.
273 |
# File 'app/models/image.rb', line 273 has_many :source_images, through: :source_image_links, source: :image |
#tags=(value) ⇒ void
This method returns an undefined value.
Override to enforce exclusive tag uniqueness before persisting.
Batch-checks all exclusive tags in a single query to avoid N+1.
544 545 546 547 548 |
# File 'app/models/image.rb', line 544 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
740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 |
# File 'app/models/image.rb', line 740 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 previous_ik_path = ik_path 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 # Bytes replaced at the SAME ImageKit path (re-upload over an existing # file). The `?v=` version we append to image URLs is inert there — # ImageKit keys its CDN on the transformation, not on unknown query # parameters — so without this the old file keeps being served for its # year-long s-maxage. A new path needs no purge: nothing is cached yet. purge_cache if previous_ik_path.present? && previous_ik_path == ik_path 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
1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 |
# File 'app/models/image.rb', line 1006 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)
1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 |
# File 'app/models/image.rb', line 1040 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.
263 |
# File 'app/models/image.rb', line 263 has_many :upscale_proposals, dependent: :destroy, inverse_of: :image |
#upscaled? ⇒ Boolean
Check if this image has already been upscaled
1022 1023 1024 |
# File 'app/models/image.rb', line 1022 def upscaled? .intersect?(ALL_UPSCALE_TAGS) end |
#video_posters ⇒ ActiveRecord::Relation<Video>
Videos using this image as their poster.
253 |
# File 'app/models/image.rb', line 253 has_many :video_posters, class_name: 'Video', foreign_key: :poster_image_id, dependent: :nullify, inverse_of: :poster_image |