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.
-
#has_cloudflare_captions? ⇒ Object
Alias for Video#has_cloudflare_captions?.
-
#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_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
235 236 237 238 239 240 241 |
# File 'app/presenters/video_base_presenter.rb', line 235 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). Chapters that start at or after the video's duration are dropped:
a Clip whose startOffset sits past the end of the video is invalid
structured data (Google rejects it), and it also strands the final Clip
without an endOffset — the "Missing field endOffset" Search Console
warning. It happens when chapters were authored against a longer cut than
the encode we actually serve (the served video was trimmed but the chapter
timestamps weren't). The chapters are sorted by start time first, since the
video_chapters association carries no default order and the next-chapter
boundary math assumes ascending starts.
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'app/presenters/video_base_presenter.rb', line 199 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.sort_by { |chapter| chapter.start_ms.to_i } return [] if chapters.empty? total = video.duration_in_seconds.to_i # Keep only chapters that begin inside the video. Guarded on a known # duration (total > 0); with an unknown duration we can't range-check, so # every chapter is kept (the last then lacks an endOffset, as before). chapters = chapters.select { |chapter| chapter.start_ms.to_i / 1000 < total } if total.positive? return [] if chapters.empty? chapters.each_with_index.map do |chapter, i| start_s = chapter.start_ms.to_i / 1000 # Next chapter's start, or the video duration for the final chapter. Every # surviving start is < total, so the last clip always gets an endOffset # when the duration is known. 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
227 228 229 230 231 232 |
# File 'app/presenters/video_base_presenter.rb', line 227 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
251 252 253 254 255 |
# File 'app/presenters/video_base_presenter.rb', line 251 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
243 244 245 246 247 248 |
# File 'app/presenters/video_base_presenter.rb', line 243 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
312 |
# File 'app/presenters/video_base_presenter.rb', line 312 delegate :title, :created_at, to: :video |
#format_duration(seconds) ⇒ Object
Duration formatting methods
258 259 260 261 262 263 264 265 266 267 268 269 270 |
# File 'app/presenters/video_base_presenter.rb', line 258 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
272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'app/presenters/video_base_presenter.rb', line 272 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
307 308 309 |
# File 'app/presenters/video_base_presenter.rb', line 307 def has_assemblyai_transcript_id? video.assemblyai_transcript_id.present? end |
#has_audio_extraction? ⇒ Boolean
292 293 294 |
# File 'app/presenters/video_base_presenter.rb', line 292 def has_audio_extraction? video.audio_extraction_upload.present? end |
#has_cloudflare_captions? ⇒ Object
Alias for Video#has_cloudflare_captions?
296 |
# File 'app/presenters/video_base_presenter.rb', line 296 delegate :has_cloudflare_captions?, to: :video |
#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
288 289 290 |
# File 'app/presenters/video_base_presenter.rb', line 288 def has_structured_transcript_json? video.structured_transcript_json.present? end |
#page_description ⇒ Object
314 315 316 317 |
# File 'app/presenters/video_base_presenter.rb', line 314 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
312 |
# File 'app/presenters/video_base_presenter.rb', line 312 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
298 299 300 |
# File 'app/presenters/video_base_presenter.rb', line 298 def transcript_duration video.duration_in_seconds end |
#transcript_language ⇒ Object
302 303 304 |
# File 'app/presenters/video_base_presenter.rb', line 302 def transcript_language 'en' # Default language for now end |
#video_medias_url ⇒ Object
319 320 321 322 |
# File 'app/presenters/video_base_presenter.rb', line 319 def video_medias_url # Default URL for video media index - subclasses can override for localization "#{WEB_HOSTNAME}/videos" end |