Class: VideoBasePresenter
- Inherits:
-
BasePresenter
- Object
- SimpleDelegator
- BasePresenter
- VideoBasePresenter
- Defined in:
- app/presenters/video_base_presenter.rb
Overview
Presenter: video base presenter.
Direct Known Subclasses
Instance Attribute Summary
Attributes inherited from BasePresenter
#current_account, #options, #url_helper
Delegated Instance Attributes collapse
-
#created_at ⇒ Object
Alias for Video#created_at.
-
#title ⇒ Object
Alias for Video#title.
Methods inherited from BasePresenter
#can?, #capture, #concat, #content_tag, #fa_icon, #link_to, #number_to_currency, #simple_format
Instance Method Summary collapse
- #build_transcript_schema ⇒ Object
-
#cf_poster_url ⇒ Object
Common URL and thumbnail methods.
-
#chapter_clips(exclude = []) ⇒ Array<SchemaDotOrg::Clip>
Key-moment Clips built from the video's chapters (see VideoChapter).
- #cloudflare_embed_url ⇒ Object
-
#cloudflare_mp4_url ⇒ Object
MP4 URL for SEO and fallback purposes.
- #cloudflare_video_url ⇒ Object
-
#format_duration(seconds) ⇒ Object
Duration formatting methods.
- #format_duration_for_display(seconds) ⇒ Object
- #format_paragraphs_as_text(paragraphs) ⇒ Object
- #format_sentences_as_text(sentences) ⇒ Object
- #format_structured_paragraphs_as_text ⇒ Object
-
#format_timestamp(milliseconds) ⇒ Object
Transcript formatting methods.
-
#has_assemblyai_transcript_id? ⇒ Boolean
Additional convenience methods for views.
- #has_audio_extraction? ⇒ Boolean
- #has_cloudflare_captions? ⇒ Boolean
- #has_html_transcript? ⇒ Boolean
-
#has_structured_transcript_json? ⇒ Boolean
Presenter convenience methods.
- #page_description ⇒ Object
-
#published_on ⇒ ActiveSupport::TimeWithZone
Best "published on our site" date for schema (uploadDate / datePublished): the air date when set (when the video went live on the site), else the record's creation time.
-
#schema_dot_org_structure(position: nil, exclude: []) ⇒ Object
Schema.org methods.
-
#transcript_display ⇒ Object
Common transcript display method.
- #transcript_duration ⇒ Object
- #transcript_language ⇒ Object
- #video_medias_url ⇒ Object
Methods inherited from BasePresenter
#h, #initialize, #present, presents, #r, #safe_present, #u
Constructor Details
This class inherits a constructor from BasePresenter
Instance Method Details
#build_transcript_schema ⇒ Object
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'app/presenters/video_base_presenter.rb', line 143 def build_transcript_schema # Skip when the (heavy) transcript columns were not selected — e.g. the # lightweight `Video.for_card_grid` projection used by listing pages, which # omits them to avoid detoasting tens of MB of JSONB per request. Accessing # an unselected column would raise ActiveModel::MissingAttributeError. return nil unless video.has_attribute?(:transcript) return nil if video.transcript.blank? # Prefer structured transcript JSON if available for better formatting if video.has_attribute?(:structured_transcript_json) && video.structured_transcript_json.present? # Use structured data for better formatting formatted_text = format_structured_paragraphs_as_text return formatted_text if formatted_text.present? end # Fallback to existing transcript field - parse HTML and extract plain text doc = Nokogiri::HTML(video.transcript) doc.text&.strip&.gsub(/\n\s*\n/, "\n\n") || '' end |
#cf_poster_url ⇒ Object
Common URL and thumbnail methods
217 218 219 220 221 222 223 |
# File 'app/presenters/video_base_presenter.rb', line 217 def cf_poster_url # Prefer our own poster image when available return video.poster_image.image_url(width: 1280, height: 720, thumbnail: true) if video.respond_to?(:poster_image) && video.poster_image.present? return unless video.cloudflare_uid "#{CF_STREAM_URL}/#{video.cloudflare_uid}/thumbnails/thumbnail.jpg" end |
#chapter_clips(exclude = []) ⇒ Array<SchemaDotOrg::Clip>
Key-moment Clips built from the video's chapters (see VideoChapter).
Returns [] unless the video_chapters association is already loaded, so
building this schema for a card grid never fires a per-card query — the
watch page preloads chapters; listings don't. Requires #path (the watch
URL) so each Clip can link to its start time, which Google requires.
endOffset is the next chapter's start (the video duration for the last
chapter); it's omitted when no later boundary is known.
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'app/presenters/video_base_presenter.rb', line 191 def chapter_clips(exclude = []) return [] if exclude.include?(:hasPart) return [] unless respond_to?(:path) return [] unless video.respond_to?(:video_chapters) && video.association(:video_chapters).loaded? chapters = video.video_chapters.to_a return [] if chapters.empty? total = video.duration_in_seconds.to_i chapters.each_with_index.map do |chapter, i| start_s = chapter.start_ms.to_i / 1000 end_s = chapters[i + 1] ? chapters[i + 1].start_ms.to_i / 1000 : total clip = SchemaDotOrg::Clip.new(name: chapter.title, startOffset: start_s, url: "#{path}#t=#{start_s}") clip.endOffset = end_s if end_s > start_s clip end end |
#cloudflare_embed_url ⇒ Object
209 210 211 212 213 214 |
# File 'app/presenters/video_base_presenter.rb', line 209 def return unless video.cloudflare_uid poster_param = cf_poster_url ? "&poster=#{URI.encode_www_form_component(cf_poster_url)}" : '' "#{CF_STREAM_URL}/#{video.cloudflare_uid}/iframe#{poster_param}" end |
#cloudflare_mp4_url ⇒ Object
MP4 URL for SEO and fallback purposes
233 234 235 236 237 |
# File 'app/presenters/video_base_presenter.rb', line 233 def cloudflare_mp4_url return nil unless video.is_cloudflare? "https://customer-ikxw003vtz2iah2s.cloudflarestream.com/#{video.cloudflare_uid}/downloads/default.mp4" end |
#cloudflare_video_url ⇒ Object
225 226 227 228 229 230 |
# File 'app/presenters/video_base_presenter.rb', line 225 def cloudflare_video_url return nil unless video.is_cloudflare? # Use HLS manifest for proper streaming instead of direct video download "https://customer-ikxw003vtz2iah2s.cloudflarestream.com/#{video.cloudflare_uid}/manifest/video.m3u8" end |
#created_at ⇒ Object
Alias for Video#created_at
296 |
# File 'app/presenters/video_base_presenter.rb', line 296 delegate :title, :created_at, to: :video |
#format_duration(seconds) ⇒ Object
Duration formatting methods
240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'app/presenters/video_base_presenter.rb', line 240 def format_duration(seconds) # Convert seconds to ISO 8601 duration format (PT1M30S) return "PT#{seconds.to_i}S" if seconds < 60 minutes = (seconds / 60).to_i remaining_seconds = (seconds % 60).to_i if remaining_seconds == 0 "PT#{minutes}M" else "PT#{minutes}M#{remaining_seconds}S" end end |
#format_duration_for_display(seconds) ⇒ Object
254 255 256 257 258 259 260 261 262 263 264 265 266 267 |
# File 'app/presenters/video_base_presenter.rb', line 254 def format_duration_for_display(seconds) return '0:00' if seconds.nil? || seconds == 0 minutes = (seconds / 60).to_i remaining_seconds = (seconds % 60).to_i if minutes >= 60 hours = (minutes / 60).to_i remaining_minutes = (minutes % 60).to_i format('%d:%02d:%02d', hours, remaining_minutes, remaining_seconds) else format('%d:%02d', minutes, remaining_seconds) end end |
#format_paragraphs_as_text(paragraphs) ⇒ Object
38 39 40 41 42 43 |
# File 'app/presenters/video_base_presenter.rb', line 38 def format_paragraphs_as_text(paragraphs) paragraphs.map do |paragraph| text = paragraph['text'] || '' text end.join("\n\n").strip end |
#format_sentences_as_text(sentences) ⇒ Object
24 25 26 27 28 29 30 31 32 33 34 35 36 |
# File 'app/presenters/video_base_presenter.rb', line 24 def format_sentences_as_text(sentences) sentences.map do |sentence| # Include timestamp if available = sentence['start'] ? (sentence['start']) : nil text = sentence['text'] || '' if "[#{}] #{text}" else text end end.join("\n\n") end |
#format_structured_paragraphs_as_text ⇒ Object
45 46 47 48 49 50 51 52 53 54 55 |
# File 'app/presenters/video_base_presenter.rb', line 45 def format_structured_paragraphs_as_text return nil if video.structured_transcript_json.blank? paragraphs = video.structured_transcript_paragraphs return nil if paragraphs.empty? # Format as clean text with paragraph breaks formatted = paragraphs.pluck('text').join("\n\n") formatted.strip end |
#format_timestamp(milliseconds) ⇒ Object
Transcript formatting methods
14 15 16 17 18 19 20 21 22 |
# File 'app/presenters/video_base_presenter.rb', line 14 def (milliseconds) return '00:00' if milliseconds.nil? total_seconds = milliseconds / 1000.0 minutes = (total_seconds / 60).floor seconds = (total_seconds % 60).floor format('%02d:%02d', minutes, seconds) end |
#has_assemblyai_transcript_id? ⇒ Boolean
Additional convenience methods for views
291 292 293 |
# File 'app/presenters/video_base_presenter.rb', line 291 def has_assemblyai_transcript_id? video.assemblyai_transcript_id.present? end |
#has_audio_extraction? ⇒ Boolean
274 275 276 |
# File 'app/presenters/video_base_presenter.rb', line 274 def has_audio_extraction? video.audio_extraction_upload.present? end |
#has_cloudflare_captions? ⇒ Boolean
278 279 280 |
# File 'app/presenters/video_base_presenter.rb', line 278 def has_cloudflare_captions? video.cloudflare_captions_status.present? end |
#has_html_transcript? ⇒ Boolean
57 58 59 |
# File 'app/presenters/video_base_presenter.rb', line 57 def has_html_transcript? video.transcript.present? && video.transcript.include?("\n\n") && video.transcript.exclude?('<') end |
#has_structured_transcript_json? ⇒ Boolean
Presenter convenience methods
270 271 272 |
# File 'app/presenters/video_base_presenter.rb', line 270 def has_structured_transcript_json? video.structured_transcript_json.present? end |
#page_description ⇒ Object
298 299 300 301 |
# File 'app/presenters/video_base_presenter.rb', line 298 def page_description # Allow subclasses to override this method for localization video..presence || video.title end |
#published_on ⇒ ActiveSupport::TimeWithZone
Best "published on our site" date for schema (uploadDate / datePublished):
the air date when set (when the video went live on the site), else the
record's creation time. YouTube's upload date is deliberately ignored —
YouTube is a secondary distribution channel, not our canonical publish date.
Returned as a zone-aware time (start of the air day in the app time zone)
so the serialized ISO 8601 string carries a timezone offset. A bare Date
serializes to "2016-05-26", which Google Search Console rejects for
VideoObject.uploadDate with "Datetime property is missing a timezone".
174 175 176 |
# File 'app/presenters/video_base_presenter.rb', line 174 def published_on (video.air_date || video.created_at).in_time_zone end |
#schema_dot_org_structure(position: nil, exclude: []) ⇒ Object
Schema.org methods
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'app/presenters/video_base_presenter.rb', line 85 def schema_dot_org_structure(position: nil, exclude: []) ssv = SchemaDotOrg::VideoObject.new # Set required attributes (unless excluded) ssv.name = title unless exclude.include?(:name) ssv.description = page_description unless exclude.include?(:description) ssv.thumbnailUrl = thumbnail_url(width: 1280, height: 720) unless exclude.include?(:thumbnailUrl) ssv.uploadDate = published_on unless exclude.include?(:uploadDate) ssv.contentUrl = cloudflare_mp4_url unless exclude.include?(:contentUrl) ssv. = unless exclude.include?(:embedUrl) ssv.url = path if respond_to?(:path) && exclude.exclude?(:url) ssv.id_iri = id_iri if respond_to?(:id_iri) # Add missing properties from old inline markup ssv.requiresSubscription = false unless exclude.include?(:requiresSubscription) ssv.identifier = video.cloudflare_uid unless exclude.include?(:identifier) # Create author organization (unless excluded) ssv. = Www::SeoHelper.online_store_schema unless exclude.include?(:author) ssv.publisher = Www::SeoHelper.online_store_schema unless exclude.include?(:publisher) # Create thumbnail image object (unless excluded) # Deprecated explicit nested ImageObject for VideoObject per Google guidelines; keep thumbnailUrl only ssv.playerType = 'HTML5' unless exclude.include?(:playerType) ssv.width = 1280 unless exclude.include?(:width) ssv.height = 720 unless exclude.include?(:height) ssv.isFamilyFriendly = true unless exclude.include?(:isFamilyFriendly) # ssv.regionsAllowed = 'US,CA' ssv.datePublished = published_on unless exclude.include?(:datePublished) ssv.genre = 'Home Improvement' unless exclude.include?(:genre) # Set optional attributes only if they have values (unless excluded) ssv.duration = ActiveSupport::Duration.build(video.duration_in_seconds.to_i).iso8601 if video.duration_in_seconds.to_i > 0 && exclude.exclude?(:duration) # Set transcript (unless excluded) unless exclude.include?(:transcript) transcript_text = build_transcript_schema ssv.transcript = transcript_text if transcript_text.present? end # Key moments / chapters → Google "Video with key moments" rich result. # Empty when no chapters are preloaded (or :hasPart excluded); an empty array # is dropped on render. ssv.hasPart = chapter_clips(exclude) # If position is provided, wrap in ItemListElement structure if position { '@type' => 'ListItem', 'position' => position, 'item' => ssv.to_json_struct } else ssv end end |
#title ⇒ Object
Alias for Video#title
296 |
# File 'app/presenters/video_base_presenter.rb', line 296 delegate :title, :created_at, to: :video |
#transcript_display ⇒ Object
Common transcript display method
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'app/presenters/video_base_presenter.rb', line 62 def transcript_display return 'No transcript available' if video.transcript.blank? # Prefer structured transcript JSON if available if video.structured_transcript_json.present? # Use structured data for better formatting formatted_text = format_structured_paragraphs_as_text return h.simple_format(formatted_text) if formatted_text.present? end # Fallback to existing transcript field if has_html_transcript? # Display paragraphs with timestamps video.transcript.split("\n\n").map do |paragraph| h.simple_format(paragraph) end.join.html_safe else # Fallback to plain text h.simple_format(video.transcript) end end |
#transcript_duration ⇒ Object
282 283 284 |
# File 'app/presenters/video_base_presenter.rb', line 282 def transcript_duration video.duration_in_seconds end |
#transcript_language ⇒ Object
286 287 288 |
# File 'app/presenters/video_base_presenter.rb', line 286 def transcript_language 'en' # Default language for now end |
#video_medias_url ⇒ Object
303 304 305 306 |
# File 'app/presenters/video_base_presenter.rb', line 303 def video_medias_url # Default URL for video media index - subclasses can override for localization "#{WEB_HOSTNAME}/videos" end |