Exercise Video Composition
The compositional model for how exercise videos are built from filming primitives and served to patients. Implements P56.
Older spec superseded
This document supersedes video-upload.md's "one video per exercise uploaded via API" model. The platform now composes multiple videos per exercise — one MP4 per (exercise, recipe, language) tuple — from raw filming primitives. video-upload.md remains as a historical reference until the F9.1 schema audit is done.
Hybrid playback: full MP4 + metadata cue manifest
The MP4 described here is shipped to the patient client paired with a tiny metadata-only cue manifest — a JSON sidecar that maps (intro / work / rest / outro) cues to MP4 timestamps. The patient client plays the MP4 in a single <video> and reads video.currentTime against the manifest to drive overlays (rep counter, set indicator, pain context, between-exercise countdown). See cue-manifest.md for the schema + render flow. An earlier attempt at a multi-primitive audio-bundle model was abandoned after cross-device testing exposed reliability issues on weak HLS stacks.
Why flat MP4, and what it costs
The decision to bake one flat MP4 per render (rather than layer at runtime or split into a segment playlist) — plus the measured storage/egress numbers that justify it — is recorded in rendering-strategy.md.
Three render models
Authoritative taxonomy
The kind axis and the static-vs-composed split are specced in rendering-strategy.md → Render models in detail. Where mentions of "pre-baked duration_based" appear further down this page, they predate that split and are superseded by it: duration_based is now composed (timed holds), and the pre-baked single-MP4 case is static.
Every exercise has a kind that captures how its video is produced:
reps_based— primitives in S3 (intro / pause / outro / 5-rep blocks per side / per-side VO masters); composer renders one MP4 per(exercise, recipe, language)recipe. Many cached renders over time. This document focuses on this flow.duration_based— composed likereps_based, but the work segment is a timed hold rather than a rep block (dosed in holds). Same S3 bundle shape + framing cascade; many cached renders.static— one pre-baked MP4, no primitives, no composer, no recipe, one render per language. Ingested through the media service (Bunny upload + TV baseline + thumbnail) but never composed. Astaticexercise evolves in place intoreps_based/duration_basedonce re-filmed with primitives — same row,kindflips, thestaticrender is kept for rollback.
All three share the same exercises + exercise_renders data model. The difference is whether exercise_renders accumulates many rows for the exercise (reps_based / duration_based) or has one row per language (static).
What "composition" means here (reps_based only)
A patient's daily rehab session is a playlist of per-exercise videos, one MP4 per exercise. For a reps_based exercise prescribed at a specific dose (e.g. "Lumbar Detensioning, 2 sets × 10 reps, alternating sides"), the platform produces one MP4 that contains:
intro → set 1 → pause → set 2 → outroThat MP4 is not pre-existing — it's composed on demand from a bundle of raw filming primitives the filming/audio team uploads to our S3 bucket. Composition is cached at (exercise, recipe_hash, language) so multiple treatment plans prescribing the same exercise at the same dose share the same render.
The actual composition engine lives in services/media/; the algorithm was iterated in experiments/exercise-composer/. This document is the spec the engine implements.
Two views of the catalog
| Audience | What they see | Underlying data |
|---|---|---|
| Console / Clinic app | The raw exercise — rep/hold video as the main media (reps_based / duration_based) or the pre-baked MP4 (static), plus intro/pause/outro assets, language tabs, instructions, metadata. Used for creating treatment plans / guided sessions / browsing the library. | exercises row + S3 asset bundle reference (composed kinds) or the ingested video_id (static). |
| Portal (patient) | One playable Bunny video per exercise — the canonical "catalog preview". For reps_based: a pre-baked render of intro + 5 reps left + pause + 5 reps right + outro (or one side if unilateral). For duration_based: a composed hold render. For static: the pre-baked MP4 itself. Used when a patient does a random exercise outside a prescribed session. | exercises.default_preview_render_id → one row in exercise_renders. |
This avoids the patient-catalog explosion problem: even if lumbar-detensioning has been rendered at 2×5, 2×10, and 3×5 for various treatment plans, the patient catalog shows one entry with the canonical preview. Other renders only surface when a patient opens a prescribed session that uses them.
The catalog preview recipe for reps_based is derived automatically from manifest.sides:
sides: ["left", "right"] → preview = [{left, 5}, {right, 5}]
sides: ["left"] → preview = [{left, 5}]No need to store the preview recipe on the exercise; it's a convention enforced by API at publish time.
The asset bundle
Per exercise, the filming/audio team delivers a bundle organised in S3 under s3://restartix-exercise-assets-{env}/{exercise-slug}/:
{exercise-slug}/
├── manifest.json # technical composition contract
├── intro-video-{1,2,3}.mp4 # silent/breathing-only framing, 3 variants
├── pause-video-{1,2,3}.mp4 # inter-set rest, 3 variants
├── outro-video-{1,2,3}.mp4 # wrap-up, 3 variants
├── rep-left-video-{1,2,3}.mp4 # 5-rep block, left side, breathing only
├── rep-right-video-{1,2,3}.mp4 # 5-rep block, right side, breathing only
└── audio/{lang}/
├── intro-vo-{1,2,3}.{mp3|wav} # coached VO for intro, 3 variants
├── pause-vo-{1,2,3}.{mp3|wav} # coached VO for pause, 3 variants
├── outro-vo-{1,2,3}.{mp3|wav} # coached VO for outro, 3 variants
├── rep-left-vo-{1,2,3}.{mp3|wav} # 20-count coached VO matching left rep tempo
└── rep-right-vo-{1,2,3}.{mp3|wav}# 20-count coached VO matching right rep tempoEach VO slot ships as either .mp3 or .wav — whichever the audio team has on hand. ffmpeg decodes both; the bake re-encodes to AAC regardless, so the choice has no impact on the rendered output beyond avoiding a needless mp3→aac double-lossy hop when the wav source is uploaded. If both .mp3 and .wav happen to be present for the same (slot, variant) (e.g. mid-migration), the .wav wins and the .mp3 becomes dead weight. Pair-locking is on the variant index, not the extension, so this is safe.
Total ~30 files per exercise per language. ~280 MB on disk for an exercise like lumbar detensioning (more for all-wav bundles — wav is roughly 10× the size of 128-kbps mp3).
The manifest
manifest.json is the technical contract the composer reads at job start:
{
"exercise": "lumbar-detensioning",
"reps_per_video_block": 5,
"counts_per_audio_master": 20,
"sides": ["left", "right"],
"languages": ["ro"]
}reps_per_video_block— how many reps are in each rep video file (always 5).counts_per_audio_master— how many counts the VO master covers (always 20). Sets the maximum reps per set.sides— which sides exist. Usually["left", "right"]; bilateral exercises that don't switch sides would be a future variant.languages— which languages have audio recorded. Adding a language = addingaudio/{new_lang}/+ listing it here.
Variant counts are auto-discovered from the filesystem — the manifest does not need to declare them.
Tier-1 only. This manifest carries technical composition fields. Clinical/UI metadata (display name, categories, body regions, contraindications, difficulty, equipment) lives in the platform DB (the exercises table in F9.1), not in the manifest. The manifest is the contract between filming and composer; the DB is the contract between platform and clinicians/patients.
The production constraints
Three constraints on the filming/audio team that the entire model rests on:
1. Intro / pause / outro videos have no lip-synced dialogue
The therapist on camera in intro/pause/outro segments does not speak to camera with scripted dialogue. They gesture, demonstrate setup positions, transition into the rep starting position. Mouth movements are not tied to specific words. Reason: the VO is recorded separately per language and laid over at render time. Lip-sync mismatch (Romanian mouth shapes + English audio, or vice versa) would be jarring.
The therapist can be conversational and warm; they just can't be reading a specific script that locks the audio to their mouth.
2. Rep videos are silent (breathing only)
The therapist on camera demonstrates reps without speaking. Natural breathing sounds are fine and clinically useful (they're mixed under the VO in the final). The voice the patient hears during reps is the booth-recorded VO for that side.
3. Rep tempo is locked per exercise per side; VO tempo matches
The on-camera therapist reps at a consistent tempo across all 3 video variants of a given side, and the audio booth records the side's VO at the same tempo. The composer trusts this alignment — it doesn't time-stretch or align counts to rep boundaries dynamically. If the filming team's tempo drifts, the count word will land slightly off the rep boundary in the final.
In practice this works to within ~0.5% drift over 20 reps (~0.6s total), which is imperceptible.
The recipe (one render request)
The composer's input contract:
{
"exercise": "lumbar-detensioning",
"language": "ro",
"sets": [
{ "side": "left", "reps": 10 },
{ "side": "right", "reps": 10 }
],
"seed": 42
}exercise— slug matching an S3 directory under the assets bucket.language— whichaudio/{lang}/directory to read VO from. Defaults tomanifest.languages[0].sets— ordered list. Each set is one side, one rep count. Reps must be a multiple ofreps_per_video_block(5), capped atcounts_per_audio_master(20). Valid per-set reps:{5, 10, 15, 20}.seed— optional. When set, variant picks are deterministic — same seed produces identical renders. Used in production for cache-key stability and in testing for reproducibility.
The composer returns:
{
"video_id": "abc123-...",
"playback_hls_url": "https://vz-{token}.b-cdn.net/abc123-.../playlist.m3u8",
"duration_seconds": 191.34,
"picks": {
"intro_video": 2, "pause_video": 1, "outro_video": 3,
"intro_vo": 1, "pause_vo": 2, "outro_vo": 1,
"sets": [
{ "video_variant": 1, "vo_variant": 3 },
{ "video_variant": 2, "vo_variant": 2 }
]
}
}video_id is the persistable canonical value (store this). playback_hls_url is convenience — reconstruct at serve-time from current CDN hostname + video id, so future hostname changes don't invalidate stored URLs.
The variant model
Every slot has 3 variants delivered by filming/audio teams. Each render picks one variant per slot:
| Slot | Pick mechanism | Reused? |
|---|---|---|
intro (video + VO) | One pair picked per render | Once per render |
pause (video + VO) | One pair picked per render | Reused across every pause in the same render |
outro (video + VO) | One pair picked per render | Once per render |
rep-{side} (video + VO) | One pair picked per set | Each set picks independently |
With ~9 slot picks × 3 options each, the combinatorial space is ~20k unique renders per exercise. We bake one per (exercise, recipe, language) — most of the space is unused, but different recipes of the same exercise see different combinations naturally.
Picks are random by default, deterministic with a seed. Seeded picks are used in production so the cache key — seed = hash(exercise, recipe_hash, language) — produces the same render every time it's referenced.
The bake pipeline
When the composer receives a recipe, it does this:
- Download the asset bundle from S3 to a per-job working directory (cleaned afterwards).
- Load manifest + scan variants on disk.
- Validate the prescription — reps must be multiples of 5 in
[5, 20], sides must exist in the manifest, language must be declared. - Pick variants for every slot (seeded or random).
- Bake each unique set as a normalized MP4 segment: stream-loop the rep video N/5 times silently, mix the picked side-VO over the top with breathing at 0.35 gain, VO at 1.0 gain. De-dupe by
(side, reps, video_variant, vo_variant)— identical sets in the same prescription bake once and reuse. - Bake intro / pause / outro the same way (video + VO mixed, video duration as master clock).
- Concat all segments via ffmpeg's concat demuxer (
-c copy, no re-encode — all segments share codec params by construction). - Upload the final MP4 to Bunny Stream.
- Return the
video_id+ computed playback URL.
All segments are normalized to 1920×1080, 30fps, H.264 yuv420p, AAC 48kHz stereo. Bunny then transcodes the uploaded MP4 to its adaptive bitrate ladder.
Counts reset per set, by construction
The fresh-per-set counting model (each set hears "1, 2, …, N" regardless of session position) falls out of the de-dupe behavior. A "3 sets × 5 reps" prescription bakes one 5-rep set clip — which contains the first 5 counts of the side's 20-count VO master — and concats it in three times. There is no other way for the same baked clip to slot into multiple set positions. See Decisions: Why fresh-per-set counting for the clinical and production reasoning.
The audio mix
Inside each baked set:
- Rep video's wild track (breathing, ambient room) → volume
0.35 - Side's VO master (counting + coaching narrative) → volume
1.0
The VO dominates; breathing sits underneath as ambient texture. For lumbar detensioning specifically (a calm floor exercise where breathing pattern is part of the form), keeping the wild track audible is clinically useful — the patient hears the breathing rhythm and matches it.
The gain constants are not in the recipe — they're hard-coded in the composer. Future: expose as per-exercise manifest fields if a strenuous exercise needs different mixing (e.g. mute the wild track entirely when it's grunt + fabric rustle).
Phase 1: shipped
The API integration that wraps the composer landed in commit 3d95e38. End-to-end working state:
- ✅ Migration
000022_exercises—exercises+exercise_renderstables, RLS policies (platform-curated; SELECT for any authenticated principal, AdminPool-only writes), permission codes (exercises.read,exercises.manage),lumbar-detensioningseeded. - ✅ Go domain at services/api/internal/core/domain/exercises/ — model + repository + service + handler + errors.
Service.EnsureRenderdoes the cache lookup → composer call → persist loop. - ✅ Composer HTTP client at services/api/internal/integration/composer/ — bearer-token authenticated.
- ✅ Admin endpoint
POST /v1/admin/exercises/{slug}/renders— Console-only viaRequirePermission(exercises.manage). - ✅ Media service additions — bearer-token middleware on
/v1/exercises/*(empty token = anonymous-with-warning for dev), Bunny collection auto-create-and-cache (one collection per slug, idempotent list-or-create).
Catalog thumbnail asset (Phase 2 — locked spec)
Distinct from prescription renders. The catalog thumbnail is a small, public asset bundle generated per exercise to support fast catalog browsing in Console, Clinic, and Portal. Patients/staff scrolling through ~200 exercises see a short rep-loop preview on hover, with a static poster as the placeholder. Patients prescribed an exercise still receive the full intro→sets→outro composed video (the prescription render — that's a different table row).
Two distinct compositional outputs per exercise:
| Output | What | Used in |
|---|---|---|
| Prescription render | Full intro + sets + pause + outro, composed per recipe | Patient watches the exercise at the prescribed dose |
| Catalog thumbnail | Short looping clip + static poster, generated once per exercise | Catalog browsing in Console / Clinic / Portal |
What gets generated
Each Generate produces one loop + one poster via a random source-slice pick — different rep index every time for composed exercises (uniform over [1..5]), different 10s window for static, different poster frame in the safe band. The admin previews the URLs and then Save/Discards. Re-rolling is meaningful: the source-slice pick differs across bakes, so each call produces byte-different files with new content-addressed URLs.
duration_based exercises share the composed bake; static exercises slice the static MP4 instead of a rep clip.
Locked generation specs
MP4 looping thumbnail (composed):
Source clip : rep-left-video-1.mp4 (bilateral) or rep-video-1.mp4 (unilateral)
Slice : random rep K ∈ {1..5}, length = block_duration / 5
Speed : 5× via setpts=PTS/5 (~0.5–1 s loop output)
Resolution : 360p (640×360)
Codec : H.264 (libx264 veryfast, CRF 30, yuv420p, +faststart)
Audio : stripped (-an)
Output size : ~25 KB
MP4 looping thumbnail (static):
Source clip : static-video-{lang}.mp4
Slice : random 10 s window in [0, dur - 10 s] (whole clip if shorter)
Speed : 1× (no setpts compression)
Other : same as composed (360p, libx264 CRF 30, +faststart, silent)
Poster (both modes):
Source clip : same as loop's source
Frame : random t in [0.2 * dur, 0.8 * dur] (avoids setup / tail-off)
Resolution : 720p (1280×720)
Quality : JPEG -q:v 3 (high quality, ~70 KB)ffmpeg gotcha worth documenting (caught in the experiment): -ss and -t must both be on the input side (before -i) so they trim the source before the filter chain runs. On the output side, -t pads the encoder back to the original duration and setpts becomes a no-op.
Storage
Catalog thumbnails go to Bunny Storage Zone, NOT Bunny Stream. Storage Zone is Bunny's general-purpose public file CDN — no transcoding overhead, no player infrastructure, just public URLs.
Bunny Stream's strengths (HLS, adaptive bitrate, player) are exactly what we don't need for 25 KB clips with native <video preload="none"> hover-to-play. See decisions.md → Why Bunny Storage Zone for public-asset CDN.
Object layout under the Storage Zone (F9.2 Phase 1 tier prefix; see programs-and-assignments → Storage):
platform/exercises/{slug}/thumbnails/loop.{sha12}.mp4 # content-addressed loop
platform/exercises/{slug}/thumbnails/poster.{sha12}.jpg # content-addressed poster
platform/exercises/{slug}/thumbnails/current.json # sidecar — Save anchorContent-addressed: same bytes → same key; fresh bake → fresh sha → fresh URL the CDN can't conflate with the prior live one. The current.json sidecar carries {loop_url, poster_url} — written on Save, read by the API's reconciler to rebuild catalog_thumbnail_*_url columns post-make migrate-reset. No v{asset_version}/ subdir — thumbnail bake is decoupled from S3 bundle versions; bumping asset_version no longer regenerates the thumbnail. Sibling subdirs under platform/exercises/{slug}/ are cues/, renders/, instructions/, and static/; the per-slug grouping keeps reconcile walks single-rooted and per-exercise deletes a single prefix scan.
DB shape
Catalog thumbnail metadata lives on the exercises row directly (it's exercise-scoped, not recipe-scoped). Two columns:
catalog_thumbnail_loop_url TEXT— content-addressed Bunny Storage URL of the saved loop (…/thumbnails/loop.{sha12}.mp4)catalog_thumbnail_poster_url TEXT— content-addressed Bunny Storage URL of the saved poster (…/thumbnails/poster.{sha12}.jpg)
Generated via an explicit "Generate thumbnail" admin action; each Generate is a random source-slice pick (one loop + one poster) the admin previews and then Save/Discards. Decoupled from asset_version — admins iterate on thumbnails without re-running the full bundle re-bake pipeline.
Shared <AnimatedThumbnail> UI primitive
Single component in packages/ui/ consumed by Console, Clinic, and Portal. Props:
videoUrl: string— Bunny CDN URL for the looping MP4posterUrl: string— Bunny CDN URL for the static frameaspectRatio?: string— defaults to16/9className?: string
Behavior:
- Initial state: shows
posterUrlvia<video preload="none" poster=...>(or<img>fallback if video JS is disabled) - On hover: calls
video.play()— browser fetches the MP4 just-in-time - On mouseleave: calls
video.pause()and resetscurrentTime = 0so the next hover plays from frame 0 - Mobile: tap-to-play (or autoplay-into-view via intersection observer — TBD per app)
Phase 2 backlog
F9.1 Phase 2 scope expansion (2026-05-25) — taxonomy + pose-tracking
The "Clinical metadata" subsection below has been expanded and folded into the F9.1 Phase 2 scope alongside the per-exercise pose-tracking config. The authoritative spec is now exercise-taxonomy-pose-tracking.md — taxonomy and pose-tracking ship together as one feature with ordered sub-phases (A: taxonomy schema; B: pose engines/landmarks reference tables; C: per-exercise pose-config). The schema is in data-model.md Area 9; the product spec is in index.md → Pose tracking; the API surface is in api.md.
Composer impact: zero. The composer's manifest schema explicitly excludes clinical/UI metadata (D14 in the design doc, restated below). Adding taxonomy + pose-config does NOT bump manifest_version or asset_version — these are pure API schema migrations with no composer involvement.
Listed in roughly the order they need to land. Most map to specific clinical or operational gates.
Composer surface
- Async/queue wrapper — the composer is sync today (caller blocks ~5-15s per render). When treatment-plan creation enqueues N renders at once, the wrapper becomes a worker that consumes from a queue (River / pg-boss / SQS — pick at land time) and updates the cache row's
status. Patient seesstatus='ready'row when available. - Asset version bump endpoint — the
exercises.asset_versioncolumn exists; the Console superadmin action that bumps it (after a filming team upload) doesn't. ProbablyPOST /v1/admin/exercises/{slug}/asset-version/bump. Eager re-render of the catalog preview is part of this action. Cross-domain effect: bumpingasset_versionauto-invalidates the exercise's pose config per D9 — the DB trigger flipsexercise_pose_configs.statusto'invalidated'and revertstracking_enabledto FALSE. The Console UI for asset-version bump should surface this consequence explicitly so the clinician knows to schedule pose re-authoring. - Eager catalog preview re-render — on
asset_versionbump, immediately re-render the catalog preview recipe so the patient catalog never serves a stale preview. Prescription renders stay lazy (re-bake on next request only).
Patient surface
- Patient catalog endpoint —
GET /v1/portal/exercisesreturnspublishedexercises with theirdefault_preview_render_id-resolvedvideo_idper the patient's language. Joins throughexercise_rendersso duration_based and reps_based both surface uniformly. - Session-render lookup endpoint — given a
(exercise, recipe, language), return the cachedvideo_idor 404 if not ready. Used by treatment-plan / guided-session render-readiness gating (sessions don't become patient-visible until all their renders areready).
Console surface
- Exercise CRUD UI — list / detail / edit pages for the catalog. The metadata fields below (taxonomy, instructions, contraindications) drive what the UI shows.
- Catalog thumbnail pipeline — composer mode that generates the MP4 loop + 5 poster candidates per the locked spec above; Bunny Storage Zone wiring; Console picker UI for re-selecting the default poster; shared
<AnimatedThumbnail>primitive inpackages/ui/consumed by Console list/detail (and later Clinic + Portal). - duration_based import workflow — manual SQL today (see reference/exercise-content-pipeline.md); promote to a Console superadmin endpoint that takes a
video_id+ metadata and creates the rows.
Console scope boundary. Console is the control panel for exercises — it manages the
exercisesrow, triggers composer renders, and views the cache state (exercise_renders). It does NOT upload content. Forreps_basedexercises, S3 primitives arrive via manualaws s3 syncfrom the filming team (out of band; see content-pipeline.md → Adding a new exercise). Forduration_basedexercises, the admin uploads the MP4 to Bunny directly (dashboard or API), then pastes the resultingvideo_idinto Console — Console does not proxy file uploads. Mediated uploads (Console takes an MP4 and forwards to Bunny or S3) are explicitly out of scope; revisit only if the manual workflow becomes a real friction point.
Clinical metadata + pose-tracking — F9.1 Phase 2 scope (expanded 2026-05-25)
Now in scope as part of F9.1 Phase 2 per exercise-taxonomy-pose-tracking.md:
- Taxonomy schema —
exercise_categories,exercise_body_regions(locked platform-only),exercise_equipment, plus the four new axes per D2:exercise_movement_patterns,exercise_recovery_phases,exercise_conditions(with optional ICD-10),exercise_skill_prerequisites. Plusexercise_prerequisites(self-M:M). exercise_tagspolymorphic junction withtag_typeENUM extended to cover all seven axes.exercise_instructions(ordered steps, typed: preparation / step / form_cue / breathing / safety). Class IIa provenance columns per D3.exercise_contraindicationswithcondition_idFK (replaces freetext) + Class IIa columns. Severity:warning | contraindicated. Specialist-signoff workflow deferred per DF5 — no placeholder columns.translationsJSONB on platform-curated rows (P21) — display_name / description per language.- Difficulty enum —
beginner | intermediate | advanced(locked, per B6; no separateeffort_tier). - Per-tag deprecation columns (
deprecated_at,replaced_by_id) on every tag entity per D4. - Pose-tracking domain —
pose_engines+pose_landmarksreference tables;exercise_pose_configs(1:1 with exercise per D8) +exercise_pose_config_history;exercise_pose_landmarks(subset),exercise_pose_metrics(typed metrics per B7),exercise_pose_feedback_rules(severity-tiered, bare schema per D10). Class IIa columns throughout. pose_data_quality_overridestable per B3 — API ships, Console UI deferred to support-request workflow.- Console authoring surfaces — tag CRUD across all axes, condition picker with ICD-10 lookup, prerequisite chain editor, pose-config authoring (camera setup + landmark picker + metrics editor + feedback rules + rep success rule), pose-config history viewer.
Deferred from F9.1 Phase 2 (per DF1–DF5):
- DF1 — condition expression DSL formalization. Trigger: when pose-aggregation engine ships (separate F-tier on telemetry side). Schema today:
condition_expression TEXTwithcondition_format ENUM DEFAULT 'text_v1'discriminator. - DF2 — pose validation metrics shape (
exercise_pose_validation_runs). Trigger: when aggregator output is defined; depends on validation methodology (manual / automated / both). - DF3 — Sofia AI auto-config flow ("configurează automat din video"). Trigger: when an AI authoring agent is scoped. Schema impact today: zero —
tagged_by_principal_idalready supports AI Principals as the tagger. - DF4 — per-vocabulary org-private extension UI (Console). Schema + API ship; Console UI deferred. Trigger: first clinic feature request, OR Console redesign cycle.
- DF5 — specialist signoff / approval workflow for contraindications. No placeholder columns ship. Trigger: Class IIa preparation kickoff with regulatory counsel, OR first contraindication-related incident.
Moved to separate F-tier work (out of scope for F9.1 Phase 2):
- Pose-aggregation engine on telemetry side — the
POST /v1/pose/framesingest path + per-rep aggregator pipeline. Consumes the configs F9.1 Phase 2 ships; lives in the telemetry service. - Patient-side pose-tracking UX in Portal — consumes pose configs + drives the in-browser MediaPipe runner; depends on the aggregator.
- Program-builder integration with new taxonomy — separate F-tier work that consumes tag data (skill_prerequisite gating, movement_pattern complementarity, recovery_phase scheduling).
D14 — Composer manifest stays clinical-metadata-free
The composer's manifest.json carries technical composition fields only (reps_per_video_block, counts_per_audio_master, sides, languages). Clinical and UI metadata (display name, categories, body regions, contraindications, difficulty, equipment, pose config) live in the platform DB — never in the manifest. This separation is explicit in services/media/internal/composer/types.go (the Manifest struct) and restated as D14 in the design doc.
Consequence: adding taxonomy + pose-config in F9.1 Phase 2 does NOT bump manifest_version or asset_version. The composer ignores these fields entirely; the cache key stays (exercise, recipe_hash, language); existing renders remain valid. The asset_version invalidation flow (D9) flows the other direction: when the composer's source assets change (re-filming), the pose config invalidates — composer doesn't react to pose-config changes.
Compositional improvements
- Variant chaining for N > block-size — today, a 20-rep set replays the same picked rep video variant. The diagram in P56 alludes to chaining different variants for the second 10 reps; the composer doesn't do that yet, but the asset bundle supports it (3 variants per side).
- More languages — only Romanian (
ro) today. Adding a language ismkdir audio/{lang}/+ uploading 15 VO files per exercise + updatingmanifest.languages.
Operational
- Bunny credentials via Cat A providers resolver — env vars today,
platform_service_providersrow + 5-minute-TTL resolver before production launch. Requires hoistingservices/api/internal/core/providers/to a shared module so the media service can consume it. - Async clinical workflow — once async wrapping is in, the upstream contract becomes: treatment plan creation enqueues renders, blocks (with a progress bar?) until all are ready before showing the plan to the patient. Acceptable for now since render time is bounded and clinician-initiated.
Where this fits
- Asset source — reference/exercise-content-pipeline.md (S3 buckets, Bunny libraries, upload workflow for new exercises).
- Pattern — P56 Exercise Video Composition Pipeline (the three-stage S3 → composer → Bunny architecture).
- Decisions:
- Service implementation — services/media/
- Sandbox / iteration playground — experiments/exercise-composer/