Class: Video
- Inherits:
-
DigitalAsset
- Object
- ActiveRecord::Base
- ApplicationRecord
- DigitalAsset
- Video
- Defined in:
- app/models/video.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
Constant Summary collapse
- PRIVATE_CATEGORIES =
Private categories.
%w[ugc unlisted].freeze
- PUBLIC_CATEGORIES =
Public categories.
%w[installation news product testimonial training webinar].freeze
- CATEGORIES =
Categories.
PRIVATE_CATEGORIES + PUBLIC_CATEGORIES
- TRANSCRIPT_COLUMNS =
Heavy per-row text columns that only a video's own watch page needs.
structured_transcript_jsonalone can be ~500KB/row, so a plain SELECT *
across a card grid / listing detoasts tens of MB into Ruby. Card and
listing scopes omit these viawithout_transcript_columns; the schema
guard in VideoBasePresenter#build_transcript_schema then drops the
transcript from VideoObject markup when they weren't selected. %w[structured_transcript_json transcript].freeze
- POPULAR_TAGS =
Override popular tags for videos to exclude legacy 'no-index'
(DigitalAsset::POPULAR_TAGS - ['no-index']).freeze
- POPULAR_TAGS_FORM_HINT =
Popular tags form hint.
'for-product-page: product page video carousel. for-support-page: support section.'
Constants included from Models::Embeddable
Models::Embeddable::MAX_CONTENT_LENGTH
Constants inherited from DigitalAsset
Constants included from Models::Auditable
Models::Auditable::ALWAYS_IGNORED
Constants included from Models::Schedulable
Models::Schedulable::SIMPLE_FORM_OPTIONS
Instance Attribute Summary collapse
- #category ⇒ Object readonly
-
#youtube_id ⇒ Object
readonly
A YouTube ID is always 11 chars from [A-Za-z0-9_-]; reject anything else so a truncated/garbage id can't silently 404 every push/sync (AppSignal-free bug found 2026-07-08: two ids had a leading
_stripped on import).
Attributes included from Models::Translatable
#do_not_compact_translation_container
Attributes inherited from DigitalAsset
#force_new_slug, #refresh_cache, #title, #url
Has many collapse
- #reviews_io_videos ⇒ ActiveRecord::Relation<ReviewsIoVideo>
- #reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
- #uploads ⇒ ActiveRecord::Relation<Upload>
- #video_chapters ⇒ ActiveRecord::Relation<VideoChapter>
-
#video_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::VideoEmbedding>
Association to the partitioned embeddings table for AI search.
Methods included from Models::CrossLinkable
#inbound_content_links, #outbound_content_links
Methods included from Models::Embeddable
Methods inherited from DigitalAsset
#digital_asset_product_lines, #generated_images, #product_lines, #site_maps
Methods included from Models::Taggable
Belongs to collapse
- #poster_image ⇒ Image
-
#youtube_thumbnail_image ⇒ Image
Optional Image library asset used only for YouTube custom thumbnail push (overrides poster / Dragonfly / CF still).
Methods included from Models::Auditable
Delegated Instance Attributes collapse
-
#create_audio_extraction_upload ⇒ Object
Alias for Transcript_processor#create_audio_extraction_upload.
-
#update_transcript_data ⇒ Object
Alias for Transcript_processor#update_transcript_data.
Class Method Summary collapse
-
.ai_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are ai search.
-
.ai_search_warning? ⇒ Boolean
Check if the last AI search had issues (call from controller after running scope).
-
.all_tags(exclude_tags: []) ⇒ Object
Exclude legacy 'no-index' from tag selector lists for videos.
-
.by_max_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by max cloudflare height.
-
.by_min_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by min cloudflare height.
-
.categories_for_select ⇒ Object
Grouped list of categories for selects Returns a hash suitable for grouped_options_for_select, e.g.
-
.clear_ai_search_warning! ⇒ Object
Clear the AI search warning.
-
.cloudflare_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are cloudflare videos.
-
.embeddable_content_types ⇒ Object
Embeddable configuration - single unified embedding like Image.
-
.expanded_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are expanded description presence.
-
.for_card_grid ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are for card grid.
-
.hosted_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosted videos.
-
.hosting_type ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosting type.
-
.hybrid_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hybrid search.
-
.linkable_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are linkable videos.
-
.mark_videos_as_no_spoken_words(video_ids) ⇒ Object
Mark videos as having no spoken words in batch.
-
.meta_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta description presence.
-
.meta_title_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta title presence.
-
.public_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are public videos.
- .purge_edge_cache(index_only: true) ⇒ Object
- .ransackable_scopes(_auth_object = nil) ⇒ Object
-
.sub_header_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are sub header presence.
-
.transcript_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are transcript presence.
-
.videos_likely_no_spoken_words ⇒ Object
Find videos that likely have no spoken words (have transcript ID but no transcript data).
-
.videos_with_assemblyai_transcripts ⇒ Object
Class methods for AssemblyAI transcript management.
-
.without_transcript_columns ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are without transcript columns.
-
.youtube_id_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are youtube id presence.
Instance Method Summary collapse
-
#audio_download_ready? ⇒ Boolean
Check if audio download is ready for transcription.
- #audio_extraction_upload ⇒ Object
-
#available_translated_vtt_locales ⇒ Array<String>
Get all available translated VTT locales.
- #can_retrieve_existing_transcript? ⇒ Boolean
-
#canonical_pillar_path ⇒ Object
Locale-less canonical path nesting the video under its pillar product line, e.g.
-
#cf_animated_thumbnail_url(duration: 5, height: 480, fps: 8) ⇒ String?
Animated GIF thumbnail from Cloudflare Stream (first N seconds of video) Used for email embeds where tags are not supported.
- #cf_poster_url ⇒ Object
-
#cloudflare_all_captions ⇒ Object
Get all captions currently on Cloudflare.
-
#cloudflare_audio_download_url ⇒ Object
Get audio download URL from Cloudflare data.
-
#cloudflare_caption_for_locale(locale) ⇒ Object
Get a cached Cloudflare caption for a locale.
- #cloudflare_captions_status ⇒ Object
-
#cloudflare_default_download_url ⇒ Object
Get default video download URL from Cloudflare data.
-
#cloudflare_download_status(download_type) ⇒ Object
Get download status for a specific type.
-
#cloudflare_duration ⇒ Object
Get Cloudflare video duration from cached data.
-
#cloudflare_file_size ⇒ Object
Get Cloudflare video file size from cached data.
-
#cloudflare_input_dimensions ⇒ Object
Get Cloudflare video input dimensions from cached data.
-
#cloudflare_mp4_url ⇒ Object
MP4 URL for SEO and fallback purposes.
-
#cloudflare_processing_percentage ⇒ Object
Get Cloudflare video processing percentage from cached data.
-
#cloudflare_ready_to_stream? ⇒ Boolean
Check if video is ready to stream from cached data.
-
#cloudflare_status ⇒ Object
Get Cloudflare video status information from cached data.
- #cloudflare_video_url ⇒ Object
- #cms_url ⇒ Object
-
#content_for_embedding(_content_type = :primary) ⇒ Object
Returns unified content for embedding - combines metadata + transcript Similar to how Image combines visual analysis + metadata + tags.
- #delete_from_cloudflare ⇒ Object
- #delete_mp4_download(download_type) ⇒ Object
-
#duration_ms ⇒ Integer
Video length in milliseconds (0 when the duration is unknown).
- #enable_mp4_download(download_type) ⇒ Object
-
#enqueue_cloudflare_sync_if_newly_cloudflare ⇒ Object
Enqueue Cloudflare synchronization when a video is updated to use Cloudflare.
- #ensure_mp4_downloads_enabled ⇒ Object
-
#extract_poster_at_timestamp(timestamp_seconds = 5.0) ⇒ Object
Extract a poster frame from the video at the specified timestamp.
- #fetch_cloudflare_duration ⇒ Object
-
#fetch_duration_in_seconds ⇒ Object
Probe the video for its duration.
- #ffmpeg_location ⇒ Object
- #ffprobe_location ⇒ Object
-
#frames_count ⇒ Object
Count the number of frames in the video.
-
#gallery_square_poster_url(width:, height:) ⇒ Object
Square still for PDP gallery (ImageKit pad_resize when poster_image exists; else Cloudflare Stream clip in box).
-
#has_assemblyai_transcript_id? ⇒ Boolean
AssemblyAI transcript ID methods.
-
#has_audio_download? ⇒ Boolean
Check if audio download is enabled.
-
#has_audio_extraction? ⇒ Boolean
Convenience methods for utility views (transcription_options, cloudflare_updates).
-
#has_cloudflare_caption_for_locale?(locale) ⇒ Boolean
Check if a specific language caption exists in the cached Cloudflare data.
- #has_cloudflare_captions? ⇒ Boolean
-
#has_default_download? ⇒ Boolean
Check if default video download is enabled.
-
#has_no_spoken_words? ⇒ Boolean
Check if video has been marked as having no spoken words.
- #has_polished_vtt? ⇒ Boolean
- #has_seo_data? ⇒ Boolean
- #has_structured_transcript_json? ⇒ Boolean
-
#has_translated_vtt?(locale) ⇒ Boolean
Check if translated VTT captions are available for a specific locale.
- #indexed_video? ⇒ Boolean
- #is_cloudflare? ⇒ Boolean
- #is_hosted? ⇒ Boolean
-
#mark_as_no_spoken_words! ⇒ Object
Mark video as having no spoken words (useful for videos with only music, sound effects, etc.).
-
#missing_cloudflare_captions ⇒ Object
Get list of captions available locally but not on Cloudflare.
- #original_transcript_data ⇒ Object
-
#primary_product_line ⇒ Object
The product line that supplies this video's canonical pillar URL.
- #refresh_cloudflare_data ⇒ Object
- #refresh_duration ⇒ Object
- #related_sources ⇒ Object
- #sentences_data ⇒ Object
- #set_duration_in_seconds ⇒ Object
- #set_poster_from_attachment ⇒ Object
- #should_skip_transcription? ⇒ Boolean
- #streamio_movie ⇒ Object
- #structured_transcript_metadata ⇒ Object
-
#structured_transcript_paragraphs ⇒ Object
Structured transcript JSON methods.
- #structured_transcript_vtt_stats ⇒ Object
- #thumbnail_url(size: '300x300#c', background: '#ffffff', width: nil, height: nil, thumbnail: true) ⇒ Object
- #to_s ⇒ Object
-
#transcript_word_count ⇒ Object
Transcript methods.
-
#translations ⇒ Object
Override translations accessor to ensure it's never nil.
- #translations=(value) ⇒ Object
-
#unmark_as_no_spoken_words! ⇒ Object
Unmark video as having no spoken words (if it was incorrectly marked).
- #vtt_original_data ⇒ Object
- #vtt_original_text_for_display ⇒ Object
- #vtt_polished_data ⇒ Object
- #vtt_polished_text_for_display ⇒ Object
-
#vtt_translated_data(locale) ⇒ Array<Hash>?
Get translated VTT data for a specific locale.
-
#vtt_translated_text_for_display(locale) ⇒ Array<Hash>
Get translated VTT data formatted for display/download.
- #website_visible? ⇒ Boolean
- #youtube_chapters_generation_error ⇒ Object
- #youtube_chapters_generation_in_progress? ⇒ Boolean
-
#youtube_chapters_generation_status ⇒ Object
YouTube chapter columns (see migration).
-
#youtube_local_caption_tracks? ⇒ Boolean
True if we have timed VTT data locally that YouTube::CaptionService can upload (English and/or translations).
Methods included from Models::CrossLinkable
#case_evidence, #content_links_count, #linked_articles, #linked_content, #linked_faqs, #linked_posts, #linked_publications, #linked_showcases, #linked_videos
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::Translatable
#compact_translation_container, skip_compaction_for
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, #externally_referenced?, #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_generate_new_friendly_id?, #should_sanitize_urls?, show_hidden_tags, #slug_candidates, tag_presence, tagged_with_all, #tags_display, #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
#add_tag, #has_tag?, normalize_tag_names, not_tagged_with, #remove_tag, #tag_list, #tag_list=, #taggable_type_for_tagging, tagged_with, #tags, #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, #should_not_save_version, #stamp_record
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
#category ⇒ Object (readonly)
251 |
# File 'app/models/video.rb', line 251 validates :category, presence: true |
#youtube_id ⇒ Object (readonly)
A YouTube ID is always 11 chars from [A-Za-z0-9_-]; reject anything else so a
truncated/garbage id can't silently 404 every push/sync (AppSignal-free bug
found 2026-07-08: two ids had a leading _ stripped on import).
Validations:
- Format ({ with: Heatwave::Normalizers::YOUTUBE_ID_REGEXP, message: 'must be an 11-character YouTube video ID (letters, digits, _ or -)' })
- Allow_blank
255 256 257 258 |
# File 'app/models/video.rb', line 255 validates :youtube_id, format: { with: Heatwave::Normalizers::YOUTUBE_ID_REGEXP, message: 'must be an 11-character YouTube video ID (letters, digits, _ or -)' }, allow_blank: true |
Class Method Details
.ai_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are ai search. Active Record Scope
348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 |
# File 'app/models/video.rb', line 348 scope :ai_search, ->(query, limit: 500, max_distance: nil) { # Clear any previous warning self.last_ai_search_warning = nil return none if query.blank? # Generate query embedding via Gemini (matches stored unified video vectors). = ContentEmbedding.(query) unless self.last_ai_search_warning = 'Could not generate AI embedding for your query. Please try a different search term.' return none end # Format vector for SQL with explicit dimension cast dimensions = .size vector_literal = "[#{.join(',')}]" # Join to the partitioned embeddings table for Videos (unified Gemini vectors). base_query = joins(:video_embeddings) .where(content_embeddings_videos: { content_type: 'unified', embedding_model: ContentEmbedding::UNIFIED_MODELS }) .where.not(content_embeddings_videos: { unified_embedding: nil }) .select( "#{table_name}.*", Arel.sql(sanitize_sql_array([ "content_embeddings_videos.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) AS neighbor_distance", vector_literal ])) ) .order(Arel.sql(sanitize_sql_array([ "content_embeddings_videos.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_videos.unified_embedding::vector(#{dimensions}) <=> ?::vector(#{dimensions}) < ?", vector_literal, max_distance ])) ) end base_query.limit(limit) } |
.ai_search_warning? ⇒ Boolean
Check if the last AI search had issues (call from controller after running scope)
399 400 401 |
# File 'app/models/video.rb', line 399 def self.ai_search_warning? last_ai_search_warning.present? end |
.all_tags(exclude_tags: []) ⇒ Object
Exclude legacy 'no-index' from tag selector lists for videos
450 451 452 |
# File 'app/models/video.rb', line 450 def self.(exclude_tags: []) super(exclude_tags: + ['no-index']) end |
.by_max_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by max cloudflare height. Active Record Scope
322 323 324 325 326 |
# File 'app/models/video.rb', line 322 scope :by_max_cloudflare_height, ->(height) { return all if height.blank? where("(cloudflare_data -> 'input' ->> 'height')::integer <= ?", height.to_i) } |
.by_min_cloudflare_height ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are by min cloudflare height. Active Record Scope
316 317 318 319 320 |
# File 'app/models/video.rb', line 316 scope :by_min_cloudflare_height, ->(height) { return all if height.blank? where("(cloudflare_data -> 'input' ->> 'height')::integer >= ?", height.to_i) } |
.categories_for_select ⇒ Object
Grouped list of categories for selects
Returns a hash suitable for grouped_options_for_select, e.g.
{ 'Public' => [["product", "product"], ...], 'Private' => [["unlisted", "unlisted"], ...] }
442 443 444 445 446 447 |
# File 'app/models/video.rb', line 442 def self.categories_for_select { 'Public' => PUBLIC_CATEGORIES.map { |category| [category.titleize, category] }, 'Private' => PRIVATE_CATEGORIES.map { |category| [category.titleize, category] } } end |
.clear_ai_search_warning! ⇒ Object
Clear the AI search warning
404 405 406 |
# File 'app/models/video.rb', line 404 def self.clear_ai_search_warning! self.last_ai_search_warning = nil end |
.cloudflare_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are cloudflare videos. Active Record Scope
293 |
# File 'app/models/video.rb', line 293 scope :cloudflare_videos, -> { where.not(cloudflare_uid: [nil, '']) } |
.embeddable_content_types ⇒ Object
Embeddable configuration - single unified embedding like Image
1063 1064 1065 |
# File 'app/models/video.rb', line 1063 def self. [:primary] end |
.expanded_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are expanded description presence. Active Record Scope
311 |
# File 'app/models/video.rb', line 311 scope :expanded_description_presence, ->(param) { param.to_b ? where.not(expanded_description: [nil, '']) : where(expanded_description: [nil, '']) } |
.for_card_grid ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are for card grid. Active Record Scope
289 290 291 |
# File 'app/models/video.rb', line 289 scope :for_card_grid, -> { without_transcript_columns.includes(:poster_image, :product_lines) } |
.hosted_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosted videos. Active Record Scope
271 |
# File 'app/models/video.rb', line 271 scope :hosted_videos, -> { where.not(attachment_uid: nil) } |
.hosting_type ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hosting type. Active Record Scope
294 295 296 297 298 299 300 301 302 303 304 |
# File 'app/models/video.rb', line 294 scope :hosting_type, ->(hosting_type) { case hosting_type&.to_sym when :cloudflare cloudflare_videos when :hosted hosted_videos else all end } |
.hybrid_search ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are hybrid search. Active Record Scope
419 420 421 422 423 424 425 426 427 428 429 430 |
# File 'app/models/video.rb', line 419 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) } |
.linkable_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are linkable videos. Active Record Scope
270 |
# File 'app/models/video.rb', line 270 scope :linkable_videos, -> { active.where(category: CATEGORIES).where.not(slug: nil).cloudflare_videos } |
.mark_videos_as_no_spoken_words(video_ids) ⇒ Object
Mark videos as having no spoken words in batch
767 768 769 |
# File 'app/models/video.rb', line 767 def self.mark_videos_as_no_spoken_words(video_ids) where(id: video_ids).update_all(video_has_no_spoken_words: true) end |
.meta_description_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta description presence. Active Record Scope
309 |
# File 'app/models/video.rb', line 309 scope :meta_description_presence, ->(param) { param.to_b ? where.not(meta_description: [nil, '']) : where(meta_description: [nil, '']) } |
.meta_title_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are meta title presence. Active Record Scope
308 |
# File 'app/models/video.rb', line 308 scope :meta_title_presence, ->(param) { param.to_b ? where.not(meta_title: [nil, '']) : where(meta_title: [nil, '']) } |
.public_videos ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are public videos. Active Record Scope
269 |
# File 'app/models/video.rb', line 269 scope :public_videos, -> { active.where(category: PUBLIC_CATEGORIES).where.not(slug: nil).cloudflare_videos } |
.purge_edge_cache(index_only: true) ⇒ Object
408 409 410 411 412 413 414 415 416 |
# File 'app/models/video.rb', line 408 def self.purge_edge_cache(index_only: true) site_maps = SiteMap.where(category: 'video') urls = if index_only site_maps.where("path LIKE '%/videos'").map(&:url) else site_maps.map(&:url) end EdgeCacheWorker.perform_async('urls' => urls) if urls.present? end |
.ransackable_scopes(_auth_object = nil) ⇒ Object
432 433 434 435 436 437 |
# File 'app/models/video.rb', line 432 def self.ransackable_scopes(_auth_object = nil) %i[title_search hosting_type transcript_presence meta_title_presence meta_description_presence sub_header_presence expanded_description_presence youtube_id_presence keyword_search by_product_line_id by_product_line_path by_product_category_id_direct tag_presence by_party_ids by_item_ids show_hidden_tags tags_include exclude_tags ai_search hybrid_search not_by_product_line_id not_by_party_ids by_min_cloudflare_height by_max_cloudflare_height] end |
.sub_header_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are sub header presence. Active Record Scope
310 |
# File 'app/models/video.rb', line 310 scope :sub_header_presence, ->(param) { param.to_b ? where.not(sub_header: [nil, '']) : where(sub_header: [nil, '']) } |
.transcript_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are transcript presence. Active Record Scope
307 |
# File 'app/models/video.rb', line 307 scope :transcript_presence, ->(param) { param.to_b ? where.not(transcript: [nil, '']) : where(transcript: [nil, '']) } |
.videos_likely_no_spoken_words ⇒ Object
Find videos that likely have no spoken words (have transcript ID but no transcript data)
760 761 762 763 764 |
# File 'app/models/video.rb', line 760 def self.videos_likely_no_spoken_words where.not(assemblyai_transcript_id: nil) .where(transcript: [nil, '']) .where(structured_transcript_json: [nil, {}]) end |
.videos_with_assemblyai_transcripts ⇒ Object
Class methods for AssemblyAI transcript management
755 756 757 |
# File 'app/models/video.rb', line 755 def self.videos_with_assemblyai_transcripts where.not(assemblyai_transcript_id: nil) end |
.without_transcript_columns ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are without transcript columns. Active Record Scope
277 |
# File 'app/models/video.rb', line 277 scope :without_transcript_columns, -> { select(Video.column_names - TRANSCRIPT_COLUMNS) } |
.youtube_id_presence ⇒ ActiveRecord::Relation<Video>
A relation of Videos that are youtube id presence. Active Record Scope
312 |
# File 'app/models/video.rb', line 312 scope :youtube_id_presence, ->(param) { param.to_b ? where.not(youtube_id: [nil, '']) : where(youtube_id: [nil, '']) } |
Instance Method Details
#audio_download_ready? ⇒ Boolean
Check if audio download is ready for transcription
556 557 558 559 560 |
# File 'app/models/video.rb', line 556 def audio_download_ready? return false unless has_audio_download? cloudflare_download_status('audio') == 'ready' end |
#audio_extraction_upload ⇒ Object
694 695 696 |
# File 'app/models/video.rb', line 694 def audio_extraction_upload uploads.in_category('audio_extraction').first end |
#available_translated_vtt_locales ⇒ Array<String>
Get all available translated VTT locales
958 959 960 961 962 963 964 |
# File 'app/models/video.rb', line 958 def available_translated_vtt_locales return [] if structured_transcript_json.blank? VideoProcessing::VideoTranslationService::SUPPORTED_LOCALES.keys.select do |locale| has_translated_vtt?(locale) end end |
#can_retrieve_existing_transcript? ⇒ Boolean
722 723 724 |
# File 'app/models/video.rb', line 722 def can_retrieve_existing_transcript? has_assemblyai_transcript_id? && transcript.blank? end |
#canonical_pillar_path ⇒ Object
Locale-less canonical path nesting the video under its pillar product
line, e.g. "floor-heating/videos/how-to-install". Returns nil when the
video has no product line rooted in a catalog root slug; callers then
fall back to the flat "video-media/" path.
485 486 487 488 489 490 |
# File 'app/models/video.rb', line 485 def canonical_pillar_path base = primary_product_line&.canonical_path.presence return unless base && CatalogPathResolver::PRODUCT_LINE_ROOT_SLUGS.include?(base.split('/').first) "#{base}/videos/#{slug}" end |
#cf_animated_thumbnail_url(duration: 5, height: 480, fps: 8) ⇒ String?
Animated GIF thumbnail from Cloudflare Stream (first N seconds of video)
Used for email embeds where tags are not supported.
Cloudflare generates the GIF on the fly from the stored video.
500 501 502 503 504 |
# File 'app/models/video.rb', line 500 def cf_animated_thumbnail_url(duration: 5, height: 480, fps: 8) return nil unless is_cloudflare? "#{CF_STREAM_URL}/#{cloudflare_uid}/thumbnails/thumbnail.gif?time=0s&duration=#{duration}s&height=#{height}&fps=#{fps}" end |
#cf_poster_url ⇒ Object
469 470 471 |
# File 'app/models/video.rb', line 469 def cf_poster_url "#{CF_STREAM_URL}/#{cloudflare_uid}/thumbnails/thumbnail.jpg" end |
#cloudflare_all_captions ⇒ Object
Get all captions currently on Cloudflare
776 777 778 779 780 781 782 783 784 785 786 787 788 |
# File 'app/models/video.rb', line 776 def cloudflare_all_captions return [] unless is_cloudflare? # Use cached captions from cloudflare_data if available # This avoids real-time API calls on every page load if cloudflare_data.present? && cloudflare_data['captions'].present? cloudflare_data['captions'] else # Fallback to empty array if not cached # User can click "Refresh Cloudflare Data" to fetch latest [] end end |
#cloudflare_audio_download_url ⇒ Object
Get audio download URL from Cloudflare data
570 571 572 573 574 |
# File 'app/models/video.rb', line 570 def cloudflare_audio_download_url return nil unless has_audio_download? cloudflare_data.dig('downloads', 'audio', 'url') end |
#cloudflare_caption_for_locale(locale) ⇒ Object
Get a cached Cloudflare caption for a locale.
791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 |
# File 'app/models/video.rb', line 791 def cloudflare_caption_for_locale(locale) return unless is_cloudflare? # Map our locale codes to Cloudflare language codes cloudflare_lang = case locale.to_s when 'en', 'en-US', 'en-CA', 'en-GB' 'en' when 'fr', 'fr-FR', 'fr-CA' 'fr' when 'es', 'es-ES', 'es-MX' 'es' when 'pl', 'pl-PL' 'pl' else locale.to_s.split('-').first end cloudflare_all_captions.find { |caption| caption['language'] == cloudflare_lang } end |
#cloudflare_captions_status ⇒ Object
771 772 773 |
# File 'app/models/video.rb', line 771 def cloudflare_captions_status cloudflare_operations.captions_status end |
#cloudflare_default_download_url ⇒ Object
Get default video download URL from Cloudflare data
563 564 565 566 567 |
# File 'app/models/video.rb', line 563 def cloudflare_default_download_url return nil unless has_default_download? cloudflare_data.dig('downloads', 'default', 'url') end |
#cloudflare_download_status(download_type) ⇒ Object
Get download status for a specific type
577 578 579 580 581 |
# File 'app/models/video.rb', line 577 def cloudflare_download_status(download_type) return nil unless is_cloudflare? cloudflare_data.dig('downloads', download_type, 'status') end |
#cloudflare_duration ⇒ Object
Get Cloudflare video duration from cached data
909 910 911 912 913 |
# File 'app/models/video.rb', line 909 def cloudflare_duration return nil unless is_cloudflare? cloudflare_data['duration'] end |
#cloudflare_file_size ⇒ Object
Get Cloudflare video file size from cached data
889 890 891 892 893 |
# File 'app/models/video.rb', line 889 def cloudflare_file_size return nil unless is_cloudflare? cloudflare_data['size'] end |
#cloudflare_input_dimensions ⇒ Object
Get Cloudflare video input dimensions from cached data
896 897 898 899 900 901 902 903 904 905 906 |
# File 'app/models/video.rb', line 896 def cloudflare_input_dimensions return nil unless is_cloudflare? input = cloudflare_data['input'] return nil unless input { width: input['width'], height: input['height'] } end |
#cloudflare_mp4_url ⇒ Object
MP4 URL for SEO and fallback purposes
514 515 516 517 518 |
# File 'app/models/video.rb', line 514 def cloudflare_mp4_url return nil unless is_cloudflare? "#{CF_STREAM_URL}/#{cloudflare_uid}/downloads/default.mp4" end |
#cloudflare_processing_percentage ⇒ Object
Get Cloudflare video processing percentage from cached data
879 880 881 882 883 884 885 886 |
# File 'app/models/video.rb', line 879 def cloudflare_processing_percentage return nil unless is_cloudflare? status = cloudflare_status return nil unless status status['pctComplete'] end |
#cloudflare_ready_to_stream? ⇒ Boolean
Check if video is ready to stream from cached data
872 873 874 875 876 |
# File 'app/models/video.rb', line 872 def cloudflare_ready_to_stream? return false unless is_cloudflare? cloudflare_data['readyToStream'] == true end |
#cloudflare_status ⇒ Object
Get Cloudflare video status information from cached data
865 866 867 868 869 |
# File 'app/models/video.rb', line 865 def cloudflare_status return nil unless is_cloudflare? cloudflare_data['status'] end |
#cloudflare_video_url ⇒ Object
506 507 508 509 510 511 |
# File 'app/models/video.rb', line 506 def cloudflare_video_url return nil unless is_cloudflare? # Use HLS manifest for adaptive streaming with higher bandwidth hint for maximum quality "#{CF_STREAM_URL}/#{cloudflare_uid}/manifest/video.m3u8?clientBandwidthHint=10.0" end |
#cms_url ⇒ Object
637 638 639 |
# File 'app/models/video.rb', line 637 def cms_url @cms_url ||= "#{WEB_URL}#{Web::UrlBuilder.new.process("/#{canonical_pillar_path || "video-media/#{slug}"}")}" end |
#content_for_embedding(_content_type = :primary) ⇒ Object
Returns unified content for embedding - combines metadata + transcript
Similar to how Image combines visual analysis + metadata + tags
1069 1070 1071 1072 1073 1074 1075 1076 |
# File 'app/models/video.rb', line 1069 def (_content_type = :primary) [ , , , ].flatten.compact.join("\n\n") end |
#create_audio_extraction_upload ⇒ Object
Alias for Transcript_processor#create_audio_extraction_upload
715 |
# File 'app/models/video.rb', line 715 delegate :create_audio_extraction_upload, to: :transcript_processor |
#delete_from_cloudflare ⇒ Object
860 861 862 |
# File 'app/models/video.rb', line 860 def delete_from_cloudflare cloudflare_operations.delete_video end |
#delete_mp4_download(download_type) ⇒ Object
533 534 535 |
# File 'app/models/video.rb', line 533 def delete_mp4_download(download_type) cloudflare_operations.delete_download(download_type) end |
#duration_ms ⇒ Integer
Video length in milliseconds (0 when the duration is unknown). Single basis
for the out-of-bounds chapter rule enforced in VideoChapter and
YouTube::ChapterService, so the * 1000 conversion can't drift between them.
525 526 527 |
# File 'app/models/video.rb', line 525 def duration_ms duration_in_seconds.to_i * 1000 end |
#enable_mp4_download(download_type) ⇒ Object
529 530 531 |
# File 'app/models/video.rb', line 529 def enable_mp4_download(download_type) cloudflare_operations.enable_download(download_type) end |
#enqueue_cloudflare_sync_if_newly_cloudflare ⇒ Object
Enqueue Cloudflare synchronization when a video is updated to use Cloudflare
838 839 840 841 842 843 844 845 |
# File 'app/models/video.rb', line 838 def enqueue_cloudflare_sync_if_newly_cloudflare return unless cloudflare_uid_previously_changed? && cloudflare_uid.present? Rails.logger.info "Enqueuing Cloudflare monitoring for video #{id} that was updated to use Cloudflare" # Enqueue the monitoring worker to wait for processing and create downloads CloudflareVideoMonitorWorker.perform_async(id) end |
#ensure_mp4_downloads_enabled ⇒ Object
537 538 539 |
# File 'app/models/video.rb', line 537 def ensure_mp4_downloads_enabled cloudflare_operations.ensure_downloads_enabled end |
#extract_poster_at_timestamp(timestamp_seconds = 5.0) ⇒ Object
Extract a poster frame from the video at the specified timestamp
848 849 850 851 852 853 854 855 856 857 858 |
# File 'app/models/video.rb', line 848 def ( = 5.0) return false unless is_cloudflare? || is_hosted? Rails.logger.info "Extracting poster for video #{id} at #{} seconds" service = VideoPosterExtractionService.new(self, ) service.extract_poster rescue VideoPosterExtractionService::ExtractionError => e Rails.logger.error "Failed to extract poster for video #{id}: #{e.}" false end |
#fetch_cloudflare_duration ⇒ Object
657 658 659 |
# File 'app/models/video.rb', line 657 def fetch_cloudflare_duration cloudflare_duration end |
#fetch_duration_in_seconds ⇒ Object
Probe the video for its duration
646 647 648 649 650 651 652 653 654 655 |
# File 'app/models/video.rb', line 646 def fetch_duration_in_seconds d = if is_hosted? && &.path.present? cmd = "#{ffprobe_location} -i #{.path} -show_entries format=duration -v quiet -of csv=\"p=0\"" res = `#{cmd}`.strip BigDecimal(res) elsif is_cloudflare? && cloudflare_uid.present? fetch_cloudflare_duration end d&.round end |
#ffmpeg_location ⇒ Object
603 604 605 |
# File 'app/models/video.rb', line 603 def ffmpeg_location @ffmpeg_location ||= `which ffmpeg`.presence.strip || '/usr/bin/ffmpeg' end |
#ffprobe_location ⇒ Object
607 608 609 |
# File 'app/models/video.rb', line 607 def ffprobe_location @ffprobe_location ||= `which ffprobe`.presence.strip || '/usr/bin/ffprobe' end |
#frames_count ⇒ Object
Count the number of frames in the video
677 678 679 680 681 682 683 |
# File 'app/models/video.rb', line 677 def frames_count return unless is_hosted? cmd = "#{ffprobe_location} -i #{.path} -v error -select_streams v:0 -show_entries stream=nb_frames -of default=nokey=1:noprint_wrappers=1" res = `#{cmd}`.strip res.to_i end |
#gallery_square_poster_url(width:, height:) ⇒ Object
Square still for PDP gallery (ImageKit pad_resize when poster_image exists; else Cloudflare Stream clip in box)
595 596 597 598 599 600 601 |
# File 'app/models/video.rb', line 595 def gallery_square_poster_url(width:, height:) if poster_image.present? thumbnail_url(width: width, height: height, thumbnail: true) elsif is_cloudflare? "#{cf_poster_url}?width=#{width.to_i}&height=#{height.to_i}&fit=clip" end end |
#has_assemblyai_transcript_id? ⇒ Boolean
AssemblyAI transcript ID methods
718 719 720 |
# File 'app/models/video.rb', line 718 def has_assemblyai_transcript_id? assemblyai_transcript_id.present? end |
#has_audio_download? ⇒ Boolean
Check if audio download is enabled
549 550 551 552 553 |
# File 'app/models/video.rb', line 549 def has_audio_download? return false unless is_cloudflare? cloudflare_data.dig('downloads', 'audio').present? end |
#has_audio_extraction? ⇒ Boolean
Convenience methods for utility views (transcription_options, cloudflare_updates)
703 704 705 |
# File 'app/models/video.rb', line 703 def has_audio_extraction? audio_extraction_upload.present? end |
#has_cloudflare_caption_for_locale?(locale) ⇒ Boolean
Check if a specific language caption exists in the cached Cloudflare data.
812 813 814 |
# File 'app/models/video.rb', line 812 def has_cloudflare_caption_for_locale?(locale) cloudflare_caption_for_locale(locale).present? end |
#has_cloudflare_captions? ⇒ Boolean
707 708 709 |
# File 'app/models/video.rb', line 707 def has_cloudflare_captions? has_cloudflare_caption_for_locale?('en') end |
#has_default_download? ⇒ Boolean
Check if default video download is enabled
542 543 544 545 546 |
# File 'app/models/video.rb', line 542 def has_default_download? return false unless is_cloudflare? cloudflare_data.dig('downloads', 'default').present? end |
#has_no_spoken_words? ⇒ Boolean
Check if video has been marked as having no spoken words
750 751 752 |
# File 'app/models/video.rb', line 750 def has_no_spoken_words? video_has_no_spoken_words? end |
#has_polished_vtt? ⇒ Boolean
933 934 935 936 937 |
# File 'app/models/video.rb', line 933 def has_polished_vtt? return false if structured_transcript_json.blank? structured_transcript_json['vtt_polished'].present? end |
#has_seo_data? ⇒ Boolean
698 699 700 |
# File 'app/models/video.rb', line 698 def has_seo_data? .present? && .present? && .present? end |
#has_structured_transcript_json? ⇒ Boolean
711 712 713 |
# File 'app/models/video.rb', line 711 def has_structured_transcript_json? structured_transcript_json.present? end |
#has_translated_vtt?(locale) ⇒ Boolean
Check if translated VTT captions are available for a specific locale
949 950 951 952 953 954 |
# File 'app/models/video.rb', line 949 def has_translated_vtt?(locale) return false if structured_transcript_json.blank? vtt_key = "vtt_#{locale.to_s.underscore}" structured_transcript_json[vtt_key].present? end |
#indexed_video? ⇒ Boolean
1045 1046 1047 |
# File 'app/models/video.rb', line 1045 def indexed_video? !inactive? && PRIVATE_CATEGORIES.exclude?(category) end |
#is_cloudflare? ⇒ Boolean
454 455 456 |
# File 'app/models/video.rb', line 454 def is_cloudflare? cloudflare_uid.present? end |
#is_hosted? ⇒ Boolean
458 459 460 |
# File 'app/models/video.rb', line 458 def is_hosted? .present? end |
#mark_as_no_spoken_words! ⇒ Object
Mark video as having no spoken words (useful for videos with only music, sound effects, etc.)
740 741 742 |
# File 'app/models/video.rb', line 740 def mark_as_no_spoken_words! update!(video_has_no_spoken_words: true) end |
#missing_cloudflare_captions ⇒ Object
Get list of captions available locally but not on Cloudflare
817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 |
# File 'app/models/video.rb', line 817 def missing_cloudflare_captions return [] unless is_cloudflare? missing = [] # Check English missing << { locale: 'en', name: 'English' } if has_polished_vtt? && !has_cloudflare_caption_for_locale?('en') # Check translations VideoProcessing::VideoTranslationService::SUPPORTED_LOCALES.each do |locale, info| missing << { locale: locale, name: info[:name] } if has_translated_vtt?(locale) && !has_cloudflare_caption_for_locale?(locale) end missing end |
#original_transcript_data ⇒ Object
1039 1040 1041 1042 1043 |
# File 'app/models/video.rb', line 1039 def original_transcript_data return nil if structured_transcript_json.blank? structured_transcript_json['original_transcript'] end |
#poster_image ⇒ Image
239 |
# File 'app/models/video.rb', line 239 belongs_to :poster_image, class_name: 'Image', optional: true |
#primary_product_line ⇒ Object
The product line that supplies this video's canonical pillar URL.
Videos may belong to several product lines; the first (lowest
digital_asset_product_lines.position) is canonical — the same one
that drives the breadcrumb.
477 478 479 |
# File 'app/models/video.rb', line 477 def primary_product_line product_lines.first end |
#refresh_cloudflare_data ⇒ Object
833 834 835 |
# File 'app/models/video.rb', line 833 def refresh_cloudflare_data cloudflare_operations.refresh_data end |
#refresh_duration ⇒ Object
667 668 669 670 671 672 673 674 |
# File 'app/models/video.rb', line 667 def refresh_duration if set_duration_in_seconds save! true else false end end |
#related_sources ⇒ Object
1053 1054 1055 1056 1057 1058 1059 1060 |
# File 'app/models/video.rb', line 1053 def Source.where( 'name LIKE ? OR name LIKE ? OR description LIKE ?', title.present? ? "%#{title}%" : nil, youtube_title.present? ? "%#{youtube_title}%" : nil, youtube_id.present? ? "%#{youtube_id}%" : nil ) end |
#reviews_io_videos ⇒ ActiveRecord::Relation<ReviewsIoVideo>
237 |
# File 'app/models/video.rb', line 237 has_many :reviews_io_videos, dependent: :destroy |
#reviews_ios ⇒ ActiveRecord::Relation<ReviewsIo>
238 |
# File 'app/models/video.rb', line 238 has_many :reviews_ios, through: :reviews_io_videos |
#sentences_data ⇒ Object
1033 1034 1035 1036 1037 |
# File 'app/models/video.rb', line 1033 def sentences_data return nil if structured_transcript_json.blank? structured_transcript_json['sentences'] end |
#set_duration_in_seconds ⇒ Object
661 662 663 664 665 |
# File 'app/models/video.rb', line 661 def set_duration_in_seconds return unless (d = fetch_duration_in_seconds) self.duration_in_seconds = d end |
#set_poster_from_attachment ⇒ Object
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 |
# File 'app/models/video.rb', line 611 def return if .blank? new_name = File.basename(.name, '.*') temp_path = Rails.root.join(Rails.application.config.x.temp_storage_path.to_s, "#{new_name}_#{Time.current.to_i}.jpg") = {} [:seek_time] = poster_offset.to_i / 1000 if poster_offset.present? streamio_movie.screenshot(temp_path.to_s, **) # Create a new Image record for the poster poster_image = Image.new( title: "#{title} - Poster", tags: ['video-poster'], source: 'video-extraction', new_image: File.open(temp_path), skip_notify: true ) if poster_image.save self.poster_image = poster_image Rails.logger.info "Created poster image #{poster_image.id} for video #{id}" else Rails.logger.error "Failed to create poster image for video #{id}: #{poster_image.errors.}" end end |
#should_skip_transcription? ⇒ Boolean
726 727 728 729 730 731 732 733 734 735 736 737 |
# File 'app/models/video.rb', line 726 def should_skip_transcription? # Skip if video has been marked as having no spoken words return true if video_has_no_spoken_words? # Skip if we already have a complete transcript return true if transcript.present? # Skip if we have a transcript ID but no transcript data (we'll try to retrieve it) return true if can_retrieve_existing_transcript? false end |
#streamio_movie ⇒ Object
462 463 464 465 466 467 |
# File 'app/models/video.rb', line 462 def streamio_movie return unless is_hosted? require 'streamio-ffmpeg' FFMPEG::Movie.new(.path) end |
#structured_transcript_metadata ⇒ Object
922 923 924 925 926 |
# File 'app/models/video.rb', line 922 def return {} if structured_transcript_json.blank? structured_transcript_json['metadata'] || {} end |
#structured_transcript_paragraphs ⇒ Object
Structured transcript JSON methods
916 917 918 919 920 |
# File 'app/models/video.rb', line 916 def structured_transcript_paragraphs return [] if structured_transcript_json.blank? structured_transcript_json['paragraphs'] || [] end |
#structured_transcript_vtt_stats ⇒ Object
928 929 930 931 |
# File 'app/models/video.rb', line 928 def structured_transcript_vtt_stats # Legacy feature - return empty hash {} end |
#thumbnail_url(size: '300x300#c', background: '#ffffff', width: nil, height: nil, thumbnail: true) ⇒ Object
583 584 585 586 587 588 589 590 591 592 |
# File 'app/models/video.rb', line 583 def thumbnail_url(size: '300x300#c', background: '#ffffff', width: nil, height: nil, thumbnail: true) return cf_poster_url unless poster_image # Support both legacy size parameter and new width/height parameters if width.present? || height.present? poster_image.image_url(width: width, height: height, thumbnail: thumbnail, background: background) else poster_image.image_url(size: size, thumbnail: thumbnail, background: background) end end |
#to_s ⇒ Object
641 642 643 |
# File 'app/models/video.rb', line 641 def to_s "#{title} [#{id}]" end |
#transcript_word_count ⇒ Object
Transcript methods
686 687 688 689 690 |
# File 'app/models/video.rb', line 686 def transcript_word_count return 0 if transcript.blank? transcript.split(/\s+/).count end |
#translations ⇒ Object
Override translations accessor to ensure it's never nil
125 126 127 |
# File 'app/models/video.rb', line 125 def translations super || {} end |
#translations=(value) ⇒ Object
129 130 131 |
# File 'app/models/video.rb', line 129 def translations=(value) super(value || {}) end |
#unmark_as_no_spoken_words! ⇒ Object
Unmark video as having no spoken words (if it was incorrectly marked)
745 746 747 |
# File 'app/models/video.rb', line 745 def unmark_as_no_spoken_words! update!(video_has_no_spoken_words: false) end |
#update_transcript_data ⇒ Object
Alias for Transcript_processor#update_transcript_data
692 |
# File 'app/models/video.rb', line 692 delegate :update_transcript_data, to: :transcript_processor |
#uploads ⇒ ActiveRecord::Relation<Upload>
236 |
# File 'app/models/video.rb', line 236 has_many :uploads, as: :resource, dependent: :destroy |
#video_chapters ⇒ ActiveRecord::Relation<VideoChapter>
183 184 185 186 |
# File 'app/models/video.rb', line 183 has_many :video_chapters, foreign_key: :digital_asset_id, inverse_of: :video, dependent: :destroy |
#video_embeddings ⇒ ActiveRecord::Relation<ContentEmbedding::VideoEmbedding>
Association to the partitioned embeddings table for AI search
244 245 246 |
# File 'app/models/video.rb', line 244 has_many :video_embeddings, -> { where(embeddable_type: 'Video') }, class_name: 'ContentEmbedding::VideoEmbedding', foreign_key: :embeddable_id |
#vtt_original_data ⇒ Object
991 992 993 994 995 |
# File 'app/models/video.rb', line 991 def vtt_original_data return [] if structured_transcript_json.blank? structured_transcript_json['vtt_original'] || [] end |
#vtt_original_text_for_display ⇒ Object
997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 |
# File 'app/models/video.rb', line 997 def vtt_original_text_for_display return [] if structured_transcript_json.blank? vtt_original_data.map do |caption| text = caption['text'] # Handle nested text structure if present text = text['text'] if text.is_a?(Hash) { 'start_time' => caption['start_time'], 'end_time' => caption['end_time'], 'text' => text } end end |
#vtt_polished_data ⇒ Object
1012 1013 1014 1015 1016 |
# File 'app/models/video.rb', line 1012 def vtt_polished_data return [] if structured_transcript_json.blank? structured_transcript_json['vtt_polished'] || [] end |
#vtt_polished_text_for_display ⇒ Object
1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 |
# File 'app/models/video.rb', line 1018 def vtt_polished_text_for_display return [] if structured_transcript_json.blank? vtt_polished_data.map do |caption| text = caption['text'] # Handle nested text structure from AI processing text = text['text'] if text.is_a?(Hash) { 'start_time' => caption['start_time'], 'end_time' => caption['end_time'], 'text' => text } end end |
#vtt_translated_data(locale) ⇒ Array<Hash>?
Get translated VTT data for a specific locale
969 970 971 972 973 974 |
# File 'app/models/video.rb', line 969 def vtt_translated_data(locale) return nil if structured_transcript_json.blank? vtt_key = "vtt_#{locale.to_s.underscore}" structured_transcript_json[vtt_key] end |
#vtt_translated_text_for_display(locale) ⇒ Array<Hash>
Get translated VTT data formatted for display/download
979 980 981 982 983 984 985 986 987 988 989 |
# File 'app/models/video.rb', line 979 def vtt_translated_text_for_display(locale) return [] unless has_translated_vtt?(locale) vtt_translated_data(locale).map do |caption| { 'start_time' => caption['start_time'], 'end_time' => caption['end_time'], 'text' => caption['text'] } end end |
#website_visible? ⇒ Boolean
1049 1050 1051 |
# File 'app/models/video.rb', line 1049 def website_visible? !inactive? && PUBLIC_CATEGORIES.include?(category) end |
#youtube_chapters_generation_error ⇒ Object
173 174 175 176 177 |
# File 'app/models/video.rb', line 173 def youtube_chapters_generation_error return nil unless has_attribute?(:youtube_chapters_generation_error) self[:youtube_chapters_generation_error] end |
#youtube_chapters_generation_in_progress? ⇒ Boolean
179 180 181 |
# File 'app/models/video.rb', line 179 def youtube_chapters_generation_in_progress? youtube_chapters_generation_status.in?(%w[queued processing]) end |
#youtube_chapters_generation_status ⇒ Object
YouTube chapter columns (see migration). Until db:migrate has run,
ActiveRecord does not define these accessors — avoid 500s on video#show.
167 168 169 170 171 |
# File 'app/models/video.rb', line 167 def youtube_chapters_generation_status return nil unless has_attribute?(:youtube_chapters_generation_status) self[:youtube_chapters_generation_status] end |
#youtube_local_caption_tracks? ⇒ Boolean
True if we have timed VTT data locally that YouTube::CaptionService can upload (English and/or translations).
940 941 942 943 944 |
# File 'app/models/video.rb', line 940 def youtube_local_caption_tracks? return false unless has_structured_transcript_json? has_polished_vtt? || available_translated_vtt_locales.any? end |
#youtube_thumbnail_image ⇒ Image
Optional Image library asset used only for YouTube custom thumbnail push (overrides poster / Dragonfly / CF still).
241 |
# File 'app/models/video.rb', line 241 belongs_to :youtube_thumbnail_image, class_name: 'Image', optional: true |