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
System overview
Section titled “System overview”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 byVideo#enqueue_cloudflare_syncwhen a video gains acloudflare_uid; polls for readiness and creates the MP4 download.Video#refresh_cloudflare_datamerges metadata/downloads/captions into thecloudflare_dataJSONB. - URLs (built from
cloudflare_uidon 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.
- HLS manifest —
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.
2. The Video model
Section titled “2. The Video model”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(holdsvtt_polished,vtt_original, per-localevtt_{locale}),transcript(Mobility-translatable plain text). - Chapters:
has_many :video_chapters(FKdigital_asset_id). - Cross-links:
linked_posts,linked_videos,linked_showcases,linked_publications(viaContentLink). - Listing perf:
Video.for_card_grid/without_transcript_columnsomit the multi-MB transcript JSONB columns so index/search pages don’t detoast them.
3. The <video> data-attribute contract
Section titled “3. The <video> data-attribute contract”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.
4. The player stack (JS)
Section titled “4. The player stack (JS)”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 build —
client/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 build —
client/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). - Shared —
client/js/common/shaka_support.js: the singleturbo:before-cachecleanup 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 promotedata-posteron init. - Turbo cleanup: players register in
shaka_support’s map and are destroyed onturbo:before-cacheso detachedblob:<video>s don’t error after navigation.
The modal (video-popup controller)
Section titled “The modal (video-popup controller)”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.
5. Server-side rendering
Section titled “5. Server-side rendering”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 whenhas_polished_vtt?.Www::VideoCardComponentrenders library/grid cards. The card thumbnail is a.video-popuptrigger (data-video-id,data-video-slug,data-title) that opens the modal; the title is a separatedata-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).
6. Captions & chapters
Section titled “6. Captions & chapters”- Captions are stored in
structured_transcript_json(AssemblyAI-sourced, then AI-”polished”). Served as WebVTT atGET /videos/:id/captions(.vtt)?type=polished|original|translated&locale=…→VideoMediaController#captions(formatted byVttService). The player consumes them viadata-vtt-url; Shaka side-loads and hides them until the user enables them. - Chapters are
VideoChapterrows (title,start_ms,digital_asset_id), generated for the YouTube pipeline and reused here. Served atGET /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.orgClips (§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:
- First-party
visit_events—POST /globals/visit_eventwith$video_play/$video_progress/$video_complete, attributed to the visitor’sVisitsession server-side (GlobalsController#visit_event,session[:visit_id]). CSRF viameta[name="csrf-token"]. Gated only by the Visit session (the same tier as$consentaudit events). - GA4 + ad vendors —
window.Analytics.track('Video - Start' / 'Video - Progress' / 'Video - Complete', …)→ GA4-nativevideo_start/video_progress/video_complete. Marketing-consent-gated (window.tracVis).
Details:
- Common identifier = the slug (
data-video-slug, ordata-wy-video-slugon stored embeds). Normalized across all surfaces so the tracker reads one field everywhere. - Idempotency uses a module-scoped
WeakSet, not adata-*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_openedevent; its frame loads on-open via alazy-offcanvascontroller (it used to eager-load on render, logging spurious events — cleaned up by theDeleteSpuriousContactPanelOpenedEventsbackground migration).
8. SEO / schema.org
Section titled “8. SEO / schema.org”- The watch page calls
add_page_schema(:video, vp.video);VideoBasePresenter#schema_dot_org_structurebuilds aSchemaDotOrg::VideoObject(name, description, thumbnail,contentUrl= MP4,embedUrl= iframe, transcript text). - Key-moment chapters become
hasPart→SchemaDotOrg::Clips (VideoBasePresenter#chapter_clips), each withstartOffset/endOffsetand a…#t=<seconds>URL — which the watch player honours viadata-video-player-seek-from-url-value. video_media/show.html.erbemitsog:video/twitter:player(Cloudflare iframe), the 1280×720og:image,swiftype_type 'Video', and oEmbed discovery links.
9. oEmbed & blog embeds
Section titled “9. oEmbed & blog embeds”- Provider:
Oembed::WyVideoProvider(app/services/oembed/wy_video_provider.rb) resolves a/videos/<slug>URL →Videoand renders the embed HTML with Rails’tagbuilder (no hand-escaped strings). Player types:html5(Shaka-ready<video>+ native baselinecontrols/srcso it plays in editors),iframe(Cloudflare),email(animated-GIF thumbnail link). - Storage:
EmbeddedVideoAsset < EmbeddedAsset(UUID,optionsJSONB) holds Redactor render options. The Redactor widget creates one viaPOST /api/v1/embedded_assetsand the page renders it through the provider. - API:
GET /api/v1/oembed?url=…&player_type=…(Api::V1::OembedController) →WyVideoProviderfor WY videos, falls back to YouTube/Vimeo via theruby-oembedgem.
10. Routes & controllers
Section titled “10. Routes & controllers”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.
Conventions & gotchas
Section titled “Conventions & gotchas”- Single init owner. Never add a second scanner that initializes
video[data-hls-url]— thevideo-playercontroller owns it. (See the removedautoInitShaka.) - 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_eventsride the Visit session (no extra gate); the GA4 path is marketing-consent-gated viaAnalytics.track. - Slug is the analytics key. FriendlyId keeps history, so re-slugged videos still resolve;
data-video-titleis carried for readable GA4 reports.