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:

SurfaceWhereBuildNotes
Watch pagevideo_media#showShaka UIchapters, captions, #t= deep-links
Inline playertestimonials, _floor_plan_display, etc.Shaka corenative controls
Modalvideo library cards, PDP galleryShaka UIbuilt on click by video-popup
Blog embedRedactor / oEmbedShaka coreserved as stored HTML
Hero backgroundlanding-page headersnone (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):

AttributeMeaning
data-hls-urlCloudflare HLS manifest
data-mp4-urlMP4 fallback
data-vtt-url / data-vtt-lang / data-vtt-labelside-loaded captions
data-video-slug / data-video-titleengagement-tracking identity
data-wy-video-id / data-wy-video-slugoEmbed/blog tracking identity
data-posterlazy poster (deferred, promoted to poster on init)

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

Value attributeEffect
data-video-player-shaka-ui-valueuse the Shaka UI build (controls + chapter markers) instead of core
data-video-player-lazy-valuedefer init until the element scrolls/slides into view
data-video-player-chapters-url-valueWebVTT chapters track to load
data-video-player-seek-from-url-valuehonour 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), generated for the YouTube pipeline and reused here. 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).

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.
  • 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):

RouteActionNotes
GET /videosindexall public videos, keyset-paginated (24), cached 24h
GET /videos/:idshowwatch page; 301s to canonical pillar/flat path
GET /videos/searchsearchRansack filtering; never cached
GET /videos/webinarswebinarspast-webinar grid
GET /videos/:id/captions(.vtt)captionsWebVTT captions
GET /videos/:id/chapters(.vtt)chaptersWebVTT 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.