Class: VideoBasePresenter

Inherits:
BasePresenter show all
Defined in:
app/presenters/video_base_presenter.rb

Overview

Presenter: video base presenter.

Direct Known Subclasses

Crm::VideoPresenter, Www::VideoPresenter

Instance Attribute Summary

Attributes inherited from BasePresenter

#current_account, #options, #url_helper

Delegated Instance Attributes collapse

Methods inherited from BasePresenter

#can?, #capture, #concat, #content_tag, #fa_icon, #link_to, #number_to_currency, #simple_format

Instance Method Summary collapse

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_schemaObject



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_urlObject

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.

Parameters:

  • exclude (Array<Symbol>) (defaults to: [])

    schema keys to skip (honors the
    schema_dot_org_structure exclude: contract for :hasPart)

Returns:



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_urlObject



209
210
211
212
213
214
# File 'app/presenters/video_base_presenter.rb', line 209

def cloudflare_embed_url
  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_urlObject

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_urlObject



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_atObject

Alias for Video#created_at

Returns:

  • (Object)

    Video#created_at

See Also:



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
    timestamp = sentence['start'] ? format_timestamp(sentence['start']) : nil
    text = sentence['text'] || ''

    if timestamp
      "[#{timestamp}] #{text}"
    else
      text
    end
  end.join("\n\n")
end

#format_structured_paragraphs_as_textObject



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 format_timestamp(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

Returns:

  • (Boolean)


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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (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

Returns:

  • (Boolean)


270
271
272
# File 'app/presenters/video_base_presenter.rb', line 270

def has_structured_transcript_json?
  video.structured_transcript_json.present?
end

#page_descriptionObject



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.meta_description.presence || video.title
end

#published_onActiveSupport::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".

Returns:

  • (ActiveSupport::TimeWithZone)


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.embedUrl = cloudflare_embed_url 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.author = 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

#titleObject

Alias for Video#title

Returns:

  • (Object)

    Video#title

See Also:



296
# File 'app/presenters/video_base_presenter.rb', line 296

delegate :title, :created_at, to: :video

#transcript_displayObject

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_durationObject



282
283
284
# File 'app/presenters/video_base_presenter.rb', line 282

def transcript_duration
  video.duration_in_seconds
end

#transcript_languageObject



286
287
288
# File 'app/presenters/video_base_presenter.rb', line 286

def transcript_language
  'en' # Default language for now
end

#video_medias_urlObject



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