Personalized Exercise Videos¶
Overview¶
Therapists can record custom exercise demonstration videos for individual patients directly from the mobile app. When a personalized video exists, it replaces the default exercise video during that patient's workout playback.
The feature is gated by the therapistPersonalizedExerciseVideo tenant feature flag. Videos are uploaded to S3, transcoded to Cloudflare Stream, and automatically attached to workout API responses.
Architecture¶
Mobile App (Doctor) API (OktaPT-API)
┌────────────────────┐ ┌──────────────────────────┐
│ Select Patient │ │ │
│ ↓ │ │ POST /upload-url │
│ Select Exercise │ │ → Create DB record │
│ ↓ │ │ → Presigned S3 URL │
│ Record Video │ │ │
│ (expo-camera) │──PUT video────→ │ S3 (DigitalOcean Spaces)│
│ ↓ │ │ │
│ Review + Upload │──POST──────────→│ POST /:id/upload-complete│
│ ↓ │ │ → BullMQ job │
│ Success Screen │ │ │
└────────────────────┘ │ Worker: │
│ 1. Sign S3 GET URL │
│ 2. Copy to Cloudflare │
│ 3. Poll readiness │
│ 4. Validate duration │
│ 5. Activate (deactivate│
│ old video if any) │
└──────────────────────────┘
Mobile App Flow (Okta-Mobile)¶
The recording flow is a 4-screen Stack navigator at app/(doctor)/(personalized-video)/. It is only accessible to doctors (wrapped in the (doctor) RoleGuard group).
Screen 1: Patient Picker (index.tsx)¶
- Fetches the doctor's patients via
GET /v2/personalized-exercise-videos/patients - Searchable
FlatListwith client-side filtering by first + last name - Each row shows initials avatar and full name
- On selection, navigates to exercise search with
patientIdandpatientNameparams
Screen 2: Exercise Search (exercise-search.tsx)¶
- Debounced search (300ms) against
GET /v2/exercises?q=&limit=700 - On exercise selection, checks for an existing personalized video via
GET /v2/personalized-exercise-videos/current?patientId=&exerciseId= - If one exists, shows a confirmation alert asking to replace it
- Create exercise button in the list footer allows creating a new exercise inline (name from search text, category "OTHER")
Screen 3: Record (record.tsx)¶
- Full-screen camera using
expo-camera(CameraView, back-facing, video mode) - Camera flip button (disabled during recording)
- Max recording duration: 120 seconds
- Countdown timer during recording
- After recording, shows a review screen with "Retake" and "Upload" buttons
Upload sequence:
1. POST /upload-url with { patientId, exerciseId } -> { id, uploadUrl }
2. Read local video file as blob
3. PUT uploadUrl with Content-Type: video/mp4 (direct S3 upload)
4. POST /:id/upload-complete to trigger processing
5. Navigate to success screen
Screen 4: Success (success.tsx)¶
- Green checkmark, patient name, exercise name
- Explanatory text: the video will appear during the patient's workout once processing completes
- "Done" button returns to the main dashboard
- Back navigation disabled
Entry Point¶
The DoctorDashboard component conditionally shows a "Record Exercise" button on each patient card when hasFeature("therapistPersonalizedExerciseVideo") is true (from TenantFeaturesContext). Tapping it navigates to the personalized video flow with the patient pre-selected.
Mobile Service¶
File: services/personalizedVideoService.ts
| Method | HTTP Call |
|---|---|
listPatients() |
GET /v2/personalized-exercise-videos/patients |
searchExercises(q, limit, offset) |
GET /v2/exercises?q=&limit=&offset= |
createExercise({ name, categoryId }) |
POST /v2/exercises |
getUploadUrl(patientId, exerciseId) |
POST /v2/personalized-exercise-videos/upload-url |
uploadComplete(videoId) |
POST /v2/personalized-exercise-videos/{id}/upload-complete |
getCurrentAssignment(patientId, exerciseId) |
GET /v2/personalized-exercise-videos/current?patientId=&exerciseId= |
API Layer (OktaPT-API)¶
Routes¶
File: src/routes/personalized-exercise-videos/index.ts
All routes require authentication middleware. Each controller checks the therapistPersonalizedExerciseVideo feature flag and returns 404 if disabled.
| Method | Path | Controller | Description |
|---|---|---|---|
| GET | /v2/personalized-exercise-videos/patients |
listPatients |
List patients available for video recording (tenant-aware) |
| POST | /v2/personalized-exercise-videos/upload-url |
createUploadUrl |
Create video record + presigned S3 PUT URL |
| POST | /v2/personalized-exercise-videos/:id/upload-complete |
uploadComplete |
Confirm upload, trigger processing |
| GET | /v2/personalized-exercise-videos/current |
getCurrentAssignment |
Get active video for a (patient, exercise) pair |
Patient Access¶
Patient access follows the same pattern as clinical assessments:
- If allActivePatientsDashboard flag is enabled: any active patient in the tenant
- Otherwise: only the doctor's directly connected patients
S3 Storage¶
Videos are stored at personalized-video/{videoId}/{timestamp}.mp4 in DigitalOcean Spaces. Presigned URLs expire after 1 hour.
Processing Pipeline¶
File: src/routes/personalized-exercise-videos/personalizedVideoProcessor.ts
When uploadComplete is called:
1. Status set to PROCESSING_QUEUED
2. BullMQ job enqueued (or inline processing in dev when no REDIS_URL)
The processor runs these steps:
- Generate signed S3 GET URL for the uploaded video
- Copy to Cloudflare Stream via
POST /stream/copyto the Cloudflare API - Poll readiness -- up to 30 attempts, 5 seconds apart (~2.5 minutes max), waiting for
readyToStream === true - Validate duration -- if over 120 seconds, delete the Cloudflare asset and mark as
FAILED - Activate in a Prisma transaction:
- Deactivate any existing active video for the same (patient, exercise) pair (
isActive: false,status: INACTIVE) - Set the new video to
isActive: true,status: COMPLETED
BullMQ Queue¶
File: src/queue/personalizedVideoQueue.ts
- Queue name:
personalized-exercise-video - Job ID:
personalized-video-{id}(prevents duplicates) - Retry: 3 attempts with exponential backoff (10s base)
- Retention: 100 completed, 200 failed jobs
- Worker concurrency: 1 (in
src/worker.ts)
Workout Integration¶
File: src/services/personalizedVideoService.ts
The attachPersonalizedVideos() function is called during GET /v2/workouts/patient-upcoming. It:
1. Collects all exercise IDs from the workout response
2. Batch-fetches active COMPLETED personalized videos for those exercises
3. Mutates each exercise object to include personalizedVideo: { id, cloudflareStreamId, updatedAt } | null
Both the web and mobile video players prioritize personalizedVideo.cloudflareStreamId as the highest-priority video source, above Kinescope and default Cloudflare Stream videos. On web, the shared getVideoSource() resolver in OktaPT-FE/lib/utils/exercise.ts handles the priority logic for VideoPreviewModal, ExerciseThumbnailRow, and the workout page. On mobile, WorkoutVideoPlayer.tsx uses the same priority order.
Database Model¶
enum PersonalizedExerciseVideoStatus {
CREATED
UPLOAD_URL_CREATED
PROCESSING_QUEUED
COMPLETED
FAILED
INACTIVE
}
model PersonalizedExerciseVideo {
id Int @id @default(autoincrement())
patientId Int
exerciseId Int
doctorId Int
storageKey String?
cloudflareStreamId String?
durationSec Float?
status PersonalizedExerciseVideoStatus @default(CREATED)
isActive Boolean @default(false)
tenantId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([patientId, exerciseId, isActive, createdAt])
@@index([tenantId, status])
}
Status State Machine¶
CREATED -> UPLOAD_URL_CREATED -> PROCESSING_QUEUED -> COMPLETED
|
v
FAILED (retryable via uploadComplete)
COMPLETED -> INACTIVE (when replaced by a newer video for the same patient+exercise)
Key Files¶
| File | Role |
|---|---|
Okta-Mobile/app/(doctor)/(personalized-video)/ |
4-screen mobile recording flow |
Okta-Mobile/services/personalizedVideoService.ts |
Mobile API service |
Okta-Mobile/components/doctor/DoctorDashboard.tsx |
Entry point (Record Exercise button) |
OktaPT-API/src/routes/personalized-exercise-videos/controllers.ts |
API controllers (4 endpoints) |
OktaPT-API/src/routes/personalized-exercise-videos/personalizedVideoProcessor.ts |
S3 -> Cloudflare Stream processing |
OktaPT-API/src/services/personalizedVideoService.ts |
Workout response integration (attachPersonalizedVideos) |
OktaPT-API/src/queue/personalizedVideoQueue.ts |
BullMQ queue definition |
OktaPT-API/src/worker.ts |
Background worker (processes all queues) |
Okta-Mobile/components/workout/WorkoutVideoPlayer.tsx |
Video player (personalized video as priority source) |
Okta-Mobile/lib/exerciseUtils.ts |
Thumbnail/video source utilities |