Exercise Video & Thumbnail Rendering¶
This document explains how exercise thumbnails and videos are rendered from different video sources across the Okta-Health platform (web and mobile).
Overview¶
The platform supports multiple video hosting providers for exercise demonstration videos. When displaying thumbnails or playing videos, the system uses a priority-based fallback system to determine which source to use.
Media handling is now consolidated into a shared module per platform — the API builds a single structured ExerciseMedia object on every exercise response, and clients consume that object instead of re-deriving it from the raw fields each time. The legacy flat fields (cloudflareStreamId, kinescopeVideoId, videoUrl, photoUrl, etc.) are still returned for backward compatibility.
Supported Video Sources¶
- Personalized Video — Therapist-recorded per-patient demos (Cloudflare Stream)
- Cloudflare Stream — Primary video hosting for new content
- Kinescope — Legacy video hosting (Russian market)
- YouTube — External videos via URL
- S3 — Therapist-uploaded videos stored in DigitalOcean Spaces, served via presigned playback URL
- Static Photo — Fallback for exercises without video
Structured ExerciseMedia Type¶
All three platforms now share the same shape:
type ExerciseVideoSource =
| { provider: "cloudflare"; scope: "default" | "personalized"; streamId: string }
| { provider: "kinescope"; scope: "default"; videoId: string }
| { provider: "youtube"; scope: "default"; url: string }
| { provider: "s3"; scope: "default"; storageKey: string; playbackUrl: string };
type ExercisePhotoSource =
| { provider: "external"; url: string }
| { provider: "s3"; storageKey: string; url: string };
interface ExerciseMedia {
videoSources: ExerciseVideoSource[]; // ordered by playback priority
photoSource: ExercisePhotoSource | null;
}
The videoSources array is pre-sorted by playback priority when the API builds it. Clients pick videoSources[0] for playback and use the getThumbnailUrl() helper for thumbnails (which has its own priority order — see below).
Priority Tables¶
Playback Priority (matches videoSources array order)¶
| Rank | Source | Identifier field |
|---|---|---|
| 1 | Cloudflare Stream — Personalized | personalizedVideo.cloudflareStreamId |
| 2 | Cloudflare Stream — Default | cloudflareStreamId |
| 3 | Kinescope | kinescopeVideoId |
| 4 | YouTube | videoUrl |
| 5 | S3 (uploaded) | uploadedVideoKey → presigned playbackUrl |
Thumbnail Priority (used by getThumbnailUrl())¶
| Rank | Source | Notes |
|---|---|---|
| 1 | Cloudflare Stream — Personalized | customer-…cloudflarestream.com/{id}/thumbnails/thumbnail.jpg?time=1s |
| 2 | Kinescope | kinescope.io/{id}/poster.jpg — fast and reliable |
| 3 | Cloudflare Stream — Default | Cloudflare thumbnail at 1s |
| 4 | YouTube | img.youtube.com/vi/{id}/hqdefault.jpg |
| 5 | Static photo (photoSource.url) |
External URL or signed S3 URL |
Note: Playback prefers Cloudflare Stream over Kinescope (better playback experience). Thumbnails prefer Kinescope over default Cloudflare (faster, more reliable poster image). Personalized videos always win on both.
API Layer (OktaPT-API)¶
Module: src/media/exerciseMedia.ts
attachExerciseMedia(exercise, requestContext?) / attachExerciseMediaList(exercises, requestContext?)¶
Adds a media: ExerciseMedia field to any object that already has the raw media fields (videoUrl, photoUrl, uploadedVideoKey, uploadedPhotoKey, cloudflareStreamId, kinescopeVideoId, optional personalizedVideo). This is the standard way endpoints expose media to clients — call it once per response and the client gets the structured object alongside the legacy fields.
buildExerciseMedia(rawMedia, options?)¶
The lower-level builder. Constructs the prioritized videoSources array and resolves the photo source. For S3-backed videos and photos, it lazily signs storage GET URLs through the request context.
createExerciseMediaRequestContext(options?)¶
Returns an ExerciseMediaRequestContext that wraps S3 signing with an in-memory Map<storageKey, Promise<signedUrl>> cache. Pass a single context per request so multiple exercises in the same response don't pay for duplicate getSignedUrl calls.
exerciseMediaSelect¶
A Prisma.ExerciseSelect constant listing the columns required to build ExerciseMedia. Use it in Prisma select clauses so endpoints fetch exactly the fields they need.
buildLegacyExerciseMediaPayload(rawMedia, requestContext?)¶
Backward-compatibility helper. Returns the old-shape payload (videoStreamId, videoPlaybackUrl, photoSignedUrl, videoProvider, plus video/photo aliases) used by routes that haven't migrated to the structured type yet.
Helper functions exported by the module¶
getExercisePlaybackSource(media)— returnsvideoSources[0](ornull).getExerciseThumbnailUrl(media)— applies thumbnail priority and returns a URL string ornull.hasPlayableExerciseVideo(media)— returnstrueifvideoSourcesis non-empty.getVideoPlaybackUrl(source)— turns a singleExerciseVideoSourceinto a playable URL (Cloudflare iframe, Kinescope page, raw YouTube URL, or already-signed S3 URL).- Provider-specific URL builders:
getCloudflarePlaybackUrl,getCloudflareThumbnailUrl,getKinescopePlaybackUrl,getKinescopeThumbnailUrl,getYouTubeVideoId,getYouTubeThumbnailUrl.
Mobile (Okta-Mobile)¶
Module: lib/exerciseMedia.ts
The mobile module mirrors the API types so client code can consume ExerciseMedia directly. Because some endpoints still return the legacy flat shape, the mobile module also ships adaptLegacyExerciseMedia(carrier) which inspects the raw fields and synthesizes a structured ExerciseMedia value with the same priority order.
Key exports:
adaptLegacyExerciseMedia(carrier)— converts a legacy exercise object into structuredExerciseMedia.getPlaybackSource(input)/getPlaybackSources(input)— returns the top source (or all sources). Accepts either a structuredExerciseMediaor a legacy carrier —resolveExerciseMediapicks the right path automatically.getThumbnailUrl(input)— applies the thumbnail-priority chain.hasPlayableVideo(input)— convenience for "should I show a play overlay?".getCloudflareVideoThumbnail(streamId),getKinescopeThumbnail(videoId),getYouTubeThumbnail(videoId),getYouTubeVideoId(url).
Web (OktaPT-FE)¶
Module: lib/utils/exercise.ts (existing) — still home to getExerciseThumbnailUrl, hasVideoSource, getVideoSource, and the YouTube/Kinescope/Cloudflare URL helpers used by the web players.
New helper: lib/utils/enrichExerciseMedia.ts — enrichExerciseWithMedia(ex, detailsMap) fills in missing media fields on an exercise that arrived without them (e.g. an AI-generated exercise that started life as just { id, name }). It looks the exercise up in a details map (typically built from useExerciseDetails) and returns a clone with the media fields populated. Used by the plan-creation flow so thumbnails render in InitialPlanCreationModal, EditPlanModal, DayWorkoutPreview, PlanComparisonView, and SelectedExerciseCard.
Video Playback Implementation¶
Web (OktaPT-FE)¶
Component: components/patient/VideoPreviewModal.tsx
The web video player resolves the highest-priority source via the shared getVideoSource() helper and renders the matching player:
- Personalized Cloudflare —
@cloudflare/stream-react<Stream>component - Default Cloudflare —
<Stream>component - Kinescope — embedded via iframe
- YouTube — embedded via iframe with autoplay
const source = getVideoSource(exercise.exercise);
switch (source?.type) {
case "cloudflare":
return <Stream controls src={source.streamId} autoplay />;
case "kinescope":
return <iframe src={`https://kinescope.io/${source.videoId}?autoplay=true`} />;
case "youtube":
return <iframe src={getYouTubeEmbedUrl(source.url)} />;
}
Mobile (Okta-Mobile)¶
Component: components/workout/WorkoutVideoPlayer.tsx
The mobile player builds a sources array via getPlaybackSources() and steps through it on error (sourceIndex state). Each source uses the appropriate native renderer:
| Provider | Renderer |
|---|---|
| Cloudflare (any scope) | WebView pointing at iframe.cloudflarestream.com/{id} |
| Kinescope | WebView pointing at the in-app embed page (/embed/k/{id}) hosted on web |
| YouTube | react-native-youtube-iframe |
| S3 | Native <Video> component using the presigned playback URL |
Kinescope Embed Page: OktaPT-FE/pages/embed/k/[id].tsx — minimal Next.js page that wraps the Kinescope iframe so the mobile WebView avoids Kinescope's mobile-detection issues.
Thumbnail Display Components¶
Web: components/patient/ExerciseThumbnailRow.tsx¶
- 84×84 px, rounded corners
- Spinner while the image loads, placeholder SVG on error
- Semi-transparent play overlay (with a play icon) when the exercise has any playable video source
- Tap opens
VideoPreviewModalfor video sources, no-op for photo-only
Web: components/ExerciseSelector/SelectedExerciseCard.tsx¶
The "selected exercises" list in the workout builder also renders a 56×56 px thumbnail with a play icon overlay, using the shared getExerciseThumbnailUrl and hasVideoSource helpers. Clicking the thumbnail opens the exercise detail dialog.
Mobile: components/workout/ExerciseThumbnailRow.tsx (and exercise lists)¶
Mobile uses the shared getThumbnailUrl() from lib/exerciseMedia.ts with React Native <Image> components.
PDF Export¶
When generating PDF workout plans, thumbnails need special handling because of CORS restrictions in @react-pdf/renderer.
Key file: OktaPT-FE/lib/pdf/imageUtils.ts
- Proxy fetch — images go through
/api/image-proxyto bypass CORS. - Base64 encoding — images convert to base64 data URLs for PDF embedding.
- Batch processing —
prefetchImagesToBase64()handles multiple thumbnails in parallel. - Caching — a
Map<url, base64>avoids duplicate fetches across pages.
const thumbnailUrls = exercises.map((e) => getExerciseThumbnailUrl(e.exercise));
const thumbnailCache = await prefetchImagesToBase64(thumbnailUrls);
const base64 = thumbnailCache.get(thumbnailUrl);
Database Audit¶
Script: OktaPT-API/scripts/auditExerciseMedia.ts (run via ts-node against a database connection)
The audit script:
- Reads every active
Exerciserow and tallies the unique combinations of populated video provider columns (e.g.cloudflare+kinescope,youtube,none). - Flags exercises that have multiple video providers populated (potential ambiguity) and exercises with only an
uploadedVideoKey(the new S3 path, used to confirm migration progress). - Reads
PersonalizedExerciseVideorows and reports anyCOMPLETED && isActiverows missing acloudflareStreamId(broken processing). - Hits a curated list of read endpoints and asserts that responses include the structured
mediafield with the expected source mix.
Use it after migrations or large media-related changes to confirm no rows or endpoints regressed.
Adding a New Video Provider¶
1. Database schema¶
Add the new ID column to the Exercise model in OktaPT-API/prisma/schema.prisma:
2. API: extend ExerciseVideoSource and the builder¶
In OktaPT-API/src/media/exerciseMedia.ts:
- Add a new variant to the
ExerciseVideoSourceunion with the appropriate fields. - Add the column to
exerciseMediaSelectandRawExerciseMediaFields. - In
toExerciseMediaPersistenceInputandbuildExerciseMedia, push the new source at the correct playback-priority position. - Add a thumbnail branch to
getExerciseThumbnailUrland a playback branch togetVideoPlaybackUrl.
3. Mobile: mirror the changes¶
Apply the same union-extension and priority updates to Okta-Mobile/lib/exerciseMedia.ts (adaptLegacyExerciseMedia, getPlaybackSource, getThumbnailUrl).
Also add a renderer branch in components/workout/WorkoutVideoPlayer.tsx.
4. Web: add a renderer branch¶
Update components/patient/VideoPreviewModal.tsx and the getVideoSource resolver in lib/utils/exercise.ts.
5. Update the audit script¶
Add the new provider to buildVideoProviderSignature in scripts/auditExerciseMedia.ts so combinations get reported.
Key Files¶
| File | Role |
|---|---|
OktaPT-API/src/media/exerciseMedia.ts |
Shared API module — types, builders, attach helpers, S3 signing |
OktaPT-API/src/media/__tests__/exerciseMedia.test.ts |
Unit tests for the shared API module |
OktaPT-API/scripts/auditExerciseMedia.ts |
DB + endpoint media audit |
Okta-Mobile/lib/exerciseMedia.ts |
Mobile mirror — types, adaptLegacyExerciseMedia, thumbnail/playback helpers |
Okta-Mobile/lib/__tests__/exerciseMedia.test.ts |
Mobile unit tests |
Okta-Mobile/components/workout/WorkoutVideoPlayer.tsx |
Multi-provider mobile video player |
OktaPT-FE/lib/utils/exercise.ts |
Web utility functions (thumbnail/video URL helpers, getVideoSource) |
OktaPT-FE/lib/utils/enrichExerciseMedia.ts |
Hydrate AI-generated exercises with media for plan-creation flows |
OktaPT-FE/components/patient/VideoPreviewModal.tsx |
Web video player modal |
OktaPT-FE/components/patient/ExerciseThumbnailRow.tsx |
Web thumbnail row |
OktaPT-FE/components/ExerciseSelector/SelectedExerciseCard.tsx |
Selected-exercise card with 56×56 thumbnail |
OktaPT-FE/pages/embed/k/[id].tsx |
Kinescope embed page (used by mobile WebView) |
OktaPT-FE/lib/pdf/imageUtils.ts |
PDF image proxy + base64 cache |
URL Patterns Quick Reference¶
Video embed URLs¶
| Provider | URL pattern |
|---|---|
| Kinescope | https://kinescope.io/{id}?autoplay=true&muted=true |
| Cloudflare iframe | https://iframe.cloudflarestream.com/{id} |
| YouTube | https://www.youtube.com/embed/{id}?autoplay=1&modestbranding=1&rel=0 |
| S3 | Presigned GET URL (TTL 1 hour, regenerated per request) |
Thumbnail URLs¶
| Provider | URL pattern |
|---|---|
| Kinescope | https://kinescope.io/{id}/poster.jpg |
| Cloudflare | https://customer-j8qsy7zjqvqbtuqp.cloudflarestream.com/{id}/thumbnails/thumbnail.jpg?time=1s |
| YouTube | https://img.youtube.com/vi/{id}/hqdefault.jpg |