Class: LatoCms::Media

Inherits:
ApplicationRecord
  • Object
show all
Includes:
LatoSpaces::Associable, LatoSpaces::AssociableRequired, LatoSpaces::AssociableUnique
Defined in:
app/models/lato_cms/media.rb

Constant Summary collapse

MEDIA_TYPES =
%w[image video document file].freeze
ALT_TEXT_ACCESSOR =

alt_text is stored as a single JSON-serialized => text hash in the alt_text column (kept as one column rather than a translations table since it's the only translatable attribute Media has). alt_text itself is overridden below to read/write the current I18n.locale's entry; alt_text_en, alt_text_it, etc. (one per configured locale) are handled dynamically so forms can render/submit them like any other attribute without predefining a method per locale.

/\Aalt_text_(?<locale>[a-z]{2}(?:_[A-Z]{2})?)(?<setter>=)?\z/

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(name, *args) ⇒ Object



94
95
96
97
98
99
100
101
102
103
# File 'app/models/lato_cms/media.rb', line 94

def method_missing(name, *args)
  match = ALT_TEXT_ACCESSOR.match(name.to_s)
  return super unless match

  if match[:setter]
    self.alt_text_translations = alt_text_translations.merge(match[:locale] => args.first)
  else
    alt_text_translations[match[:locale]].presence
  end
end

Instance Attribute Details

#actionsObject

Returns the value of attribute actions.



5
6
7
# File 'app/models/lato_cms/media.rb', line 5

def actions
  @actions
end

Class Method Details

.infer_media_type(content_type) ⇒ Object



39
40
41
42
43
44
45
46
# File 'app/models/lato_cms/media.rb', line 39

def self.infer_media_type(content_type)
  content_type = content_type.to_s
  return 'image' if content_type.start_with?('image/')
  return 'video' if content_type.start_with?('video/')
  return 'document' if content_type == 'application/pdf' || content_type.start_with?('application/vnd') || content_type.start_with?('text/')

  'file'
end

.variant_transformation(opts) ⇒ Object



48
49
50
51
52
53
54
55
56
57
58
# File 'app/models/lato_cms/media.rb', line 48

def self.variant_transformation(opts)
  opts = {} unless opts.respond_to?(:[])
  dimensions = [opts['width'] || opts[:width], opts['height'] || opts[:height]]
  return nil if dimensions.compact.empty?

  case (opts['resize'] || opts[:resize] || 'limit').to_s
  when 'fill' then { resize_to_fill: dimensions }
  when 'fit' then { resize_to_fit: dimensions }
  else { resize_to_limit: dimensions }
  end
end

Instance Method Details

#alt_text(locale = I18n.locale) ⇒ Object



76
77
78
# File 'app/models/lato_cms/media.rb', line 76

def alt_text(locale = I18n.locale)
  alt_text_translations[locale.to_s].presence
end

#alt_text=(value) ⇒ Object



80
81
82
# File 'app/models/lato_cms/media.rb', line 80

def alt_text=(value)
  self.alt_text_translations = alt_text_translations.merge(I18n.locale.to_s => value)
end

#alt_text_translationsObject



84
85
86
87
88
# File 'app/models/lato_cms/media.rb', line 84

def alt_text_translations
  JSON.parse(self[:alt_text].presence || '{}')
rescue JSON::ParserError
  {}
end

#alt_text_translations=(hash) ⇒ Object



90
91
92
# File 'app/models/lato_cms/media.rb', line 90

def alt_text_translations=(hash)
  self[:alt_text] = hash.stringify_keys.to_json
end

#as_json(_options = {}) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'app/models/lato_cms/media.rb', line 194

def as_json(_options = {})
  {
    id: id,
    name: name,
    alt_text: alt_text,
    alt_text_translations: alt_text_translations,
    media_type: media_type,
    filename: filename,
    content_type: file.attached? ? file.content_type : nil,
    byte_size: file.attached? ? file.byte_size : nil,
    url: url,
    thumbnail_url: thumbnail_url,
    poster_url: poster_url,
    usage_count: usage_count,
    created_at: created_at,
    updated_at: updated_at
  }
end

#filenameObject



72
73
74
# File 'app/models/lato_cms/media.rb', line 72

def filename
  file.filename.to_s if file.attached?
end

#generate_alt_text!(raise_on_error: false) ⇒ Object

Asks the configured OpenAI-compatible LLM for alt text in every configured locale and merges it in (existing translations for locales the LLM didn't return, or that it's re-run for, are kept/replaced individually). No-op unless an LLM is configured (see LatoCms::Config#llm_configured?) and this media is an image.

Best effort by default (raise_on_error: false): any failure is logged and swallowed, since the automatic post-upload call site (see GenerateAltTextJob) must never break the upload over a flaky LLM. The manual "Regenerate with AI" action runs as a Lato::Operation the admin is actively watching, so it passes raise_on_error: true to have failures surface there instead of disappearing silently.



148
149
150
151
152
153
154
155
156
157
158
159
# File 'app/models/lato_cms/media.rb', line 148

def generate_alt_text!(raise_on_error: false)
  return unless image? && file.attached? && LatoCms.config.llm_configured?

  translations = fetch_alt_text_translations
  raise 'The LLM returned no usable alt text' if translations.blank?

  self.alt_text_translations = alt_text_translations.merge(translations)
  save!
rescue StandardError => e
  Rails.logger.warn("LatoCms: failed to generate alt text for media #{id}: #{e.message}")
  raise if raise_on_error
end

#generate_video_poster!Object

Best effort: generates a poster from the video via Active Storage previews (ffmpeg). Any failure is logged, the video keeps working without a poster.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'app/models/lato_cms/media.rb', line 119

def generate_video_poster!
  return unless video? && file.attached?
  return if poster_file.attached?

  unless file.previewable?
    Rails.logger.warn("LatoCms: video preview unavailable (ffmpeg missing?) for media #{id}, skipping poster generation")
    return
  end

  preview = file.preview(resize_to_limit: [1280, 720]).processed
  preview.image.blob.open do |f|
    poster_file.attach(io: f, filename: "#{file.filename.base}_poster.jpg", content_type: preview.image.blob.content_type)
  end
rescue StandardError => e
  Rails.logger.warn("LatoCms: failed to generate video poster for media #{id}: #{e.message}")
end

#image?Boolean

Returns:

  • (Boolean)


60
61
62
# File 'app/models/lato_cms/media.rb', line 60

def image?
  media_type == 'image'
end

#poster_urlObject



113
114
115
# File 'app/models/lato_cms/media.rb', line 113

def poster_url
  Rails.application.routes.url_helpers.rails_blob_path(poster_file, only_path: true) if poster_file.attached?
end

#respond_to_missing?(name, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


105
106
107
# File 'app/models/lato_cms/media.rb', line 105

def respond_to_missing?(name, include_private = false)
  ALT_TEXT_ACCESSOR.match?(name.to_s) || super
end

#thumbnail_urlObject

Fixed small variant used across admin UI (Media index, picker grid, field preview) regardless of a field's own settings.sizes (used only by the public API, see variant_urls).



164
165
166
167
168
169
170
171
172
173
# File 'app/models/lato_cms/media.rb', line 164

def thumbnail_url
  return nil unless image? && file.attached? && file.variable?

  Rails.application.routes.url_helpers.rails_representation_path(
    file.variant(resize_to_fill: [200, 200]), only_path: true
  )
rescue StandardError => e
  Rails.logger.error("LatoCms: Failed to build thumbnail for media #{id}: #{e.message}")
  nil
end

#urlObject



109
110
111
# File 'app/models/lato_cms/media.rb', line 109

def url
  Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true) if file.attached?
end

#usage_countObject



68
69
70
# File 'app/models/lato_cms/media.rb', line 68

def usage_count
  page_field_media.count
end

#variant_urls(sizes_config) ⇒ Object

Builds a map of { size_name => variant_url } from a field's settings.sizes config. The config is field-owned (different fields can request different crops of the same reused Media); the mechanics live here since Media owns the attached blob.



179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/models/lato_cms/media.rb', line 179

def variant_urls(sizes_config)
  return {} if sizes_config.blank? || !sizes_config.respond_to?(:each_pair) || !file.attached? || !file.variable?

  url_helpers = Rails.application.routes.url_helpers
  sizes_config.each_with_object({}) do |(name, opts), acc|
    transformation = self.class.variant_transformation(opts)
    next if transformation.blank?

    acc[name] = url_helpers.rails_representation_path(file.variant(transformation), only_path: true)
  end
rescue StandardError => e
  Rails.logger.error("LatoCms: Failed to build image variants for media #{id}: #{e.message}")
  {}
end

#video?Boolean

Returns:

  • (Boolean)


64
65
66
# File 'app/models/lato_cms/media.rb', line 64

def video?
  media_type == 'video'
end