Skip to content

Video Infrastructure

End-to-end reference for how video is stored, delivered, played, embedded, tracked, and surfaced for SEO across WarmlyYours.

Related docs:

  • WWW JavaScript Architecture — where the player controllers fit in the Stimulus/Turbo stack
  • SEO System — schema.org and metadata pipeline this feeds into
  • Tracking & Consent skill — the Visit / visit_events model engagement events write to

Video at WarmlyYours is built on Cloudflare Stream for storage/adaptive delivery, with a thin set of in-house layers on top:

┌─────────────────────── Cloudflare Stream ───────────────────────┐
│ HLS manifest (.m3u8) · MP4 download · thumbnails · iframe │
└───────────────▲──────────────────────────────▲─────────────────┘
│ URLs built from cloudflare_uid │
┌──────────────────────────┴───────────┐ ┌──────────┴───────────┐
│ Video model (STI of DigitalAsset) │ │ CloudflareStreamApi │
│ uid · slug · transcript · chapters │ │ + monitor worker │
└───────┬───────────────┬──────────────┘ └───────────────────────┘
│ │
server-render │ │ oEmbed / Redactor
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ VideoPlayerComponent / │ │ Oembed::WyVideoProvider │ → stored blog HTML
│ video_card_component │ │ (EmbeddedVideoAsset) │
└───────────┬──────────────┘ └───────────┬──────────────┘
│ data-* contract on <video> │
▼ ▼
┌───────────────────────────── Stimulus `video-player` controller ─────────────────────────────┐
│ single initializer → picks Shaka *core* (native controls) or Shaka *UI* (controls+chapters) │
│ shaka_player.js · shaka_player_ui.js · shaka_support.js + video_tracking.js (engagement) │
└───────────────────────────────────────────────────────────────────────────────────────────────┘

Five surfaces render a player, all on the same stack:

Surface Where Build Notes
Watch page video_media#show Shaka UI chapters, captions, #t= deep-links
Inline player testimonials, _floor_plan_display, etc. Shaka core native controls
Modal video library cards, PDP gallery Shaka UI built on click by video-popup
Blog embed Redactor / oEmbed Shaka core served as stored HTML
Hero background landing-page headers none (native MP4) short muted loops, no Shaka

1. Storage & delivery — Cloudflare Stream

Section titled “1. Storage & delivery — Cloudflare Stream”
  • Host / constant: CF_STREAM_URL = https://customer-ikxw003vtz2iah2s.cloudflarestream.com (config/initializers/cloudflare.rb).
  • API wrapper: CloudflareStreamApi (app/services/cloudflare_stream_api.rb) — get_video, get_downloads, enable_mp4_download, list_captions, delete_video.
  • Sync worker: CloudflareVideoMonitorWorker — enqueued by Video#enqueue_cloudflare_sync when a video gains a cloudflare_uid; polls for readiness and creates the MP4 download. Video#refresh_cloudflare_data merges metadata/downloads/captions into the cloudflare_data JSONB.
  • URLs (built from cloudflare_uid on the model):
    • HLS manifest — Video#cloudflare_video_url{CF}/{uid}/manifest/video.m3u8?clientBandwidthHint=10.0
    • MP4 download — Video#cloudflare_mp4_url{CF}/{uid}/downloads/default.mp4
    • Poster — cf_poster_url; animated GIF — cf_animated_thumbnail_url; iframe — used for oEmbed/og:video.

A non-Cloudflare video can also be hosted (is_hosted? → has a Dragonfly attachment), in which case the player gets a direct file URL instead of a CF manifest.

Video < DigitalAsset (STI, type = 'Video'), app/models/video.rb.

  • Identity: cloudflare_uid (Stream id), slug (FriendlyId, the canonical engagement/analytics key — see §7), id.
  • Predicates: is_cloudflare?, is_hosted?, has_polished_vtt?, has_cloudflare_captions?.
  • URLs/paths: cloudflare_video_url, cloudflare_mp4_url, thumbnail_url, cms_url, canonical_pillar_path (→ /{pillar}/videos/{slug} when the video belongs to a catalog root, else flat /videos/{slug}).
  • Transcript/captions: assemblyai_transcript_id, structured_transcript_json (holds vtt_polished, vtt_original, per-locale vtt_{locale}), transcript (Mobility-translatable plain text).
  • Chapters: has_many :video_chapters (FK digital_asset_id).
  • Cross-links: linked_posts, linked_videos, linked_showcases, linked_publications (via ContentLink).
  • Listing perf: Video.for_card_grid / without_transcript_columns omit the multi-MB transcript JSONB columns so index/search pages don’t detoast them.

Every server-rendered player <video> carries data-controller="video-player" plus a small attribute set. Two categories, by design:

Stored-content contract — plain data-* (emitted into blog HTML by the oEmbed provider, and/or read inside the Shaka modules — never rename these, cached HTML + new JS must stay compatible):

Attribute Meaning
data-hls-url Cloudflare HLS manifest
data-mp4-url MP4 fallback
data-vtt-url / data-vtt-lang / data-vtt-label side-loaded captions
data-video-slug / data-video-title engagement-tracking identity
data-wy-video-id / data-wy-video-slug oEmbed/blog tracking identity
data-poster lazy poster (deferred, promoted to poster on init)

Controller behaviour flags — Stimulus values (data-video-player-*-value):

Value attribute Effect
data-video-player-shaka-ui-value use the Shaka UI build (controls + chapter markers) instead of core
data-video-player-lazy-value defer init until the element scrolls/slides into view
data-video-player-chapters-url-value WebVTT chapters track to load
data-video-player-seek-from-url-value honour a #t=<seconds> deep-link on load

Why the split: the URL/caption names are baked into stored blog HTML and into the Shaka modules, so renaming them breaks historical embeds and risks a cached-HTML / fresh-JS mismatch. Behaviour flags are internal to the controller, so they use the namespaced Stimulus-value convention.

One initializer. The Stimulus video-player controller (app/javascript/controllers/video_player_controller.js) is the sole owner of every player <video>. It reads the contract above and, in initPlayer(), lazy-imports the right Shaka build:

  • Core buildclient/js/common/shaka_player.js (shaka-player.compiled.js), export initShakaOn(). Adaptive HLS with the browser’s native <video controls>. Used for inline/gallery/blog players.
  • UI buildclient/js/common/shaka_player_ui.js (shaka-player.ui.js + controls.css), export initShakaUiOn(). Shaka’s own control bar + seek-bar chapter markers. Used on the watch page and the modal (data-video-player-shaka-ui-value).
  • Sharedclient/js/common/shaka_support.js: the single turbo:before-cache cleanup registry, streaming config, caption side-loading, MP4 fallback, polyfill install. Both builds import it; they stay separate webpack chunks so non-chaptered pages don’t pay for the heavier UI bundle.

Key behaviours:

  • No second initializer. A legacy autoInitShaka() scanner was removed — it raced the controller and attached the controls-less core player to UI markup. The controller is the single source of truth.
  • Lazy carousel videos defer via an IntersectionObserver (fires on scroll or carousel slide-in) and promote data-poster on init.
  • Turbo cleanup: players register in shaka_support’s map and are destroyed on turbo:before-cache so detached blob: <video>s don’t error after navigation.

app/javascript/controllers/video_popup_controller.js opens a Bootstrap modal, builds a <video>, mounts the Shaka UI player, plays under the click gesture, and destroys the player on hidden.bs.modal (it bypasses the video-player controller, so nothing else tears it down). It accepts a Cloudflare stream id (data-video-id → adaptive HLS) or a direct file URL (data-video-src → progressive MP4). The modal title is set via textContent (never interpolated into the HTML string).

The PDP product gallery (app/views/www/products/_product_gallery.html.erb) opens videos in this shared modal rather than Fancybox’s native HTML5 video, so the whole site plays through the same Shaka UI player.

  • VideoPlayerComponent (app/components/video_player_component.rb) renders the player <video> for the watch page (shaka_ui: true) and inline players. It emits the contract from §3, derives HLS/MP4/poster from the model, and side-loads captions when has_polished_vtt?.
  • Www::VideoCardComponent renders library/grid cards. The card thumbnail is a .video-popup trigger (data-video-id, data-video-slug, data-title) that opens the modal; the title is a separate data-turbo-frame="_top" link to the watch page.
  • Background hero (Www::FullWidthLandingPageHeaderComponent) renders a native <video autoplay muted loop> with an MP4 <source>no Shaka (first frame in ~500 ms vs 5-8 s with HLS; ABR buys nothing for a short ambient loop).
  • Captions are stored in structured_transcript_json (AssemblyAI-sourced, then AI-“polished”). Served as WebVTT at GET /videos/:id/captions(.vtt)?type=polished|original|translated&locale=…VideoMediaController#captions (formatted by VttService). The player consumes them via data-vtt-url; Shaka side-loads and hides them until the user enables them.
  • Chapters are VideoChapter rows (title, start_ms, digital_asset_id) and are AssemblyAI-derived and hosted-firstnot a YouTube artifact reused here. Pipeline: AssemblyAI transcript paragraphs → YouTube::ChapterService (LLM Gateway, semantic titles) → VideoChapter rows, with start_ms taken from the paragraph’s own AssemblyAI start. Generation needs only a structured transcript; VideoChapterGenerationWorker has no youtube_id gate. A YouTube link is optional and only matters for pushing the chapters into the YouTube description (ChapterService#push_chapters). Both hosted transcription and YouTube upload read the same Cloudflare asset, so one chapter set is correct for both. Served at GET /videos/:id/chapters(.vtt)VideoMediaController#chapters. The watch-page Shaka UI player loads them (data-video-player-chapters-url-value) and renders seek-bar markers; the same chapters become schema.org Clips (§8).

An AssemblyAI transcript can outrun the served encode: re-cut a video shorter after transcription and the paragraph timings still describe the longer master. Video 34545 had a transcript running to 330 s against a 230 s Cloudflare asset, producing chapters at 222 s and 281 s. A Clip whose startOffset is at or past the video duration is invalid structured data — it’s what raised the GSC “Missing field endOffset” warning — so three independent clamps defend it, all reading Video#duration_ms:

Layer Guard Behaviour
Model VideoChapter#start_within_video_duration Authoritative. No edit path (CRM form, AI generation, YouTube pull, console) can persist an out-of-bounds marker.
Generation YouTube::ChapterService#replace_chapters! Drops strays before insert so bulk paths skip them instead of raising RecordInvalid mid-transaction.
Render VideoBasePresenter#chapter_clips Defence in depth: drops out-of-range chapters and guarantees the last Clip gets an endOffset.

Each clamp is skipped when the duration is unknown (nothing to bound against).

All enqueues go through VideoChapterGenerationWorker.enqueue_guarded — it takes a row lock and refuses to queue a second run while one is queued/processing. Never call perform_async directly. Re-transcribing does not refresh chapters: WebhookProcessors::AssemblyaiProcessor refreshes transcript, polish, SEO, captions and embeddings, then enqueues chapter generation separately.

7. Engagement tracking → visit_events + GA4

Section titled “7. Engagement tracking → visit_events + GA4”

client/js/common/video_tracking.js (attachVideoTracking) is wired into the video-player and video-popup controllers and fires from every player surface. It attaches play + timeupdate-milestone (25/50/75/100%) listeners and records to two sinks:

  1. First-party visit_eventsPOST /globals/visit_event with $video_play / $video_progress / $video_complete, attributed to the visitor’s Visit session server-side (GlobalsController#visit_event, session[:visit_id]). CSRF via meta[name="csrf-token"]. Gated only by the Visit session (the same tier as $consent audit events).
  2. GA4 + ad vendorswindow.Analytics.track('Video - Start' / 'Video - Progress' / 'Video - Complete', …) → GA4-native video_start / video_progress / video_complete. Marketing-consent-gated (window.tracVis).

Details:

  • Common identifier = the slug (data-video-slug, or data-wy-video-slug on stored embeds). Normalized across all surfaces so the tracker reads one field everywhere.
  • Idempotency uses a module-scoped WeakSet, not a data-* flag — a DOM flag would serialize into Turbo’s page cache and block re-binding after a back-navigation (a restored <video> is a fresh node without listeners).
  • Background loops (video.loop) and slug-less videos are skipped.
  • Payload: { video_slug, video_title, video_percent, video_current_time, video_duration }.

The navbar contact panel records a separate $contact_panel_opened event; its frame loads on-open via a lazy-offcanvas controller (it used to eager-load on render, logging spurious events — cleaned up by the DeleteSpuriousContactPanelOpenedEvents background migration).

  • The watch page calls add_page_schema(:video, vp.video); VideoBasePresenter#schema_dot_org_structure builds a SchemaDotOrg::VideoObject (name, description, thumbnail, contentUrl = MP4, embedUrl = iframe, transcript text).
  • Key-moment chapters become hasPartSchemaDotOrg::Clips (VideoBasePresenter#chapter_clips), each with startOffset/endOffset and a …#t=<seconds> URL — which the watch player honours via data-video-player-seek-from-url-value.
  • video_media/show.html.erb emits og:video / twitter:player (Cloudflare iframe), the 1280×720 og:image, swiftype_type 'Video', and oEmbed discovery links.

The watch page is the sole canonical home of a video’s VideoObject. Listing surfaces emit none, on purpose (decided 2026-06-04). Duplicating a full VideoObject per card onto a URL where the video isn’t the primary content is what produced the GSC “uploadDate missing timezone” warnings on /support URLs. So _video_grid.html.erb passes add_schema: false explicitly, the support-portal and testimonials views no longer loop add_page_schema(:video, …), and Www::VideoCardComponent / Www::VideoSectionComponent build per-card schema that is then discarded. Do not “fix” that discard — it would re-introduce duplicate VideoObject on every listing. If one specific listing ever genuinely needs video rich results, add it deliberately at the view level for that page and pair it with Video.for_card_grid so the transcript columns aren’t detoasted.

  • Provider: Oembed::WyVideoProvider (app/services/oembed/wy_video_provider.rb) resolves a /videos/<slug> URL → Video and renders the embed HTML with Rails’ tag builder (no hand-escaped strings). Player types: html5 (Shaka-ready <video> + native baseline controls/src so it plays in editors), iframe (Cloudflare), email (animated-GIF thumbnail link).
  • Storage: EmbeddedVideoAsset < EmbeddedAsset (UUID, options JSONB) holds Redactor render options. The Redactor widget creates one via POST /api/v1/embedded_assets and the page renders it through the provider.
  • API: GET /api/v1/oembed?url=…&player_type=… (Api::V1::OembedController) → WyVideoProvider for WY videos, falls back to YouTube/Vimeo via the ruby-oembed gem.

config/routes/www.rb, VideoMediaController (app/controllers/video_media_controller.rb):

Route Action Notes
GET /videos index all public videos, keyset-paginated (24), cached 24h
GET /videos/:id show watch page; 301s to canonical pillar/flat path
GET /videos/search search Ransack filtering; never cached
GET /videos/webinars webinars past-webinar grid
GET /videos/:id/captions(.vtt) captions WebVTT captions
GET /videos/:id/chapters(.vtt) chapters WebVTT chapters track
GET /:root[/*pl_path]/videos[/search/:id] (same) pillar-nested URLs (e.g. /snow-melting/mat/videos/<slug>)

Www::VideoPresenter wraps the model for the watch view (title, description, breadcrumbs, transcript display, translations); VideoBasePresenter provides the schema/clip logic shared with other surfaces.


  • Single init owner. Never add a second scanner that initializes video[data-hls-url] — the video-player controller owns it. (See the removed autoInitShaka.)
  • Don’t rename the URL/caption data-* names. They’re a stored-content contract (oEmbed → blog HTML) and are read in the Shaka modules; renaming risks cached-HTML / fresh-JS breakage. Behaviour flags use Stimulus values precisely because they’re internal.
  • Hero/loop videos stay native MP4. Don’t route them through Shaka — it regresses first-frame time for no ABR benefit.
  • Tracking idempotency is in-memory (WeakSet), not a DOM attribute, to survive Turbo page-cache restores.
  • Consent: first-party visit_events ride the Visit session (no extra gate); the GA4 path is marketing-consent-gated via Analytics.track.
  • Slug is the analytics key. FriendlyId keeps history, so re-slugged videos still resolve; data-video-title is carried for readable GA4 reports.
  • Chapters are hosted-first, not YouTube-gated (§6), and every enqueue goes through VideoChapterGenerationWorker.enqueue_guarded.
  • The youtube_chapters_* column names keep that prefix deliberately. youtube_chapters_generation_status / _error (plus the dead, already-ignored_columns youtube_chapters_draft) live on the digital_assets STI table shared by Video/Image/DigitalAsset. online_migrations’ zero-downtime rename converts the table into a view + trigger — not a trade worth making on a core table for a cosmetic prefix. Don’t “fix” the naming; rename only via a deliberate 2-deploy initialize/finalize if it ever becomes worth it.

Only relevant to the optional YouTube push path, but it strands jobs when missed.

  • Quota resets at 00:00 America/Los_Angelesnot the server/session clock (America/Chicago, 2 h ahead). At 01:56 CT it is still 23:56 PT, i.e. the previous quota day. Re-running a quota-blocked job “tomorrow” by the CT date fails until PT rolls over; wait past 02:00 CT for a fresh 10,000 units. Once quota is spent even 1-unit reads fail.
  • YouTube::ApiClient raises QuotaExceededError both on a 429 and on a 403 whose message begins quotaExceeded: (Google formats these "reason: detail"). Bulk loops should rescue it and break.
  • Unit costs: read / videos.list = 1 · videos.update = 50 · thumbnails.set = 50 · search.list = 100 · captions insert/update = 400 · videos.insert (upload) = 1600. push_chapters = get_video (1) + update_video (50) = 51 per video. The nightly youtube_metadata_sync cron batches 50 ids per videos.list, ≈ 8 units for ~300 videos — it does not starve pushes.
  • youtube_id data bug: some rows had a leading _ stripped, leaving a 10-character id that NotFoundErrors on every push and sync. A valid YouTube id is always 11 characters, so sweep with Video.where.not(youtube_id: [nil, '']).select { _1.youtube_id.to_s.length != 11 }. Recover the real id via search.list with q=<title> on our channel, match by title, then update_columns(youtube_id: …). Re-run the sweep whenever new NotFoundErrors appear.