Skip to content

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 like reps_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. A static exercise evolves in place into reps_based / duration_based once re-filmed with primitives — same row, kind flips, the static render 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 → outro

That 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

AudienceWhat they seeUnderlying data
Console / Clinic appThe 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 tempo

Each 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:

json
{
  "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 = adding audio/{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:

json
{
  "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 — which audio/{lang}/ directory to read VO from. Defaults to manifest.languages[0].
  • sets — ordered list. Each set is one side, one rep count. Reps must be a multiple of reps_per_video_block (5), capped at counts_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:

json
{
  "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:

SlotPick mechanismReused?
intro (video + VO)One pair picked per renderOnce per render
pause (video + VO)One pair picked per renderReused across every pause in the same render
outro (video + VO)One pair picked per renderOnce per render
rep-{side} (video + VO)One pair picked per setEach 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:

  1. Download the asset bundle from S3 to a per-job working directory (cleaned afterwards).
  2. Load manifest + scan variants on disk.
  3. Validate the prescription — reps must be multiples of 5 in [5, 20], sides must exist in the manifest, language must be declared.
  4. Pick variants for every slot (seeded or random).
  5. 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.
  6. Bake intro / pause / outro the same way (video + VO mixed, video duration as master clock).
  7. Concat all segments via ffmpeg's concat demuxer (-c copy, no re-encode — all segments share codec params by construction).
  8. Upload the final MP4 to Bunny Stream.
  9. 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_exercisesexercises + exercise_renders tables, RLS policies (platform-curated; SELECT for any authenticated principal, AdminPool-only writes), permission codes (exercises.read, exercises.manage), lumbar-detensioning seeded.
  • Go domain at services/api/internal/core/domain/exercises/ — model + repository + service + handler + errors. Service.EnsureRender does 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 via RequirePermission(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:

OutputWhatUsed in
Prescription renderFull intro + sets + pause + outro, composed per recipePatient watches the exercise at the prescribed dose
Catalog thumbnailShort looping clip + static poster, generated once per exerciseCatalog 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 anchor

Content-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 MP4
  • posterUrl: string — Bunny CDN URL for the static frame
  • aspectRatio?: string — defaults to 16/9
  • className?: string

Behavior:

  • Initial state: shows posterUrl via <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 resets currentTime = 0 so 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 sees status='ready' row when available.
  • Asset version bump endpoint — the exercises.asset_version column exists; the Console superadmin action that bumps it (after a filming team upload) doesn't. Probably POST /v1/admin/exercises/{slug}/asset-version/bump. Eager re-render of the catalog preview is part of this action. Cross-domain effect: bumping asset_version auto-invalidates the exercise's pose config per D9 — the DB trigger flips exercise_pose_configs.status to 'invalidated' and reverts tracking_enabled to 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_version bump, 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 endpointGET /v1/portal/exercises returns published exercises with their default_preview_render_id-resolved video_id per the patient's language. Joins through exercise_renders so duration_based and reps_based both surface uniformly.
  • Session-render lookup endpoint — given a (exercise, recipe, language), return the cached video_id or 404 if not ready. Used by treatment-plan / guided-session render-readiness gating (sessions don't become patient-visible until all their renders are ready).

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 in packages/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 exercises row, triggers composer renders, and views the cache state (exercise_renders). It does NOT upload content. For reps_based exercises, S3 primitives arrive via manual aws s3 sync from the filming team (out of band; see content-pipeline.md → Adding a new exercise). For duration_based exercises, the admin uploads the MP4 to Bunny directly (dashboard or API), then pastes the resulting video_id into 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 schemaexercise_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. Plus exercise_prerequisites (self-M:M).
  • exercise_tags polymorphic junction with tag_type ENUM extended to cover all seven axes.
  • exercise_instructions (ordered steps, typed: preparation / step / form_cue / breathing / safety). Class IIa provenance columns per D3.
  • exercise_contraindications with condition_id FK (replaces freetext) + Class IIa columns. Severity: warning | contraindicated. Specialist-signoff workflow deferred per DF5 — no placeholder columns.
  • translations JSONB on platform-curated rows (P21) — display_name / description per language.
  • Difficulty enumbeginner | intermediate | advanced (locked, per B6; no separate effort_tier).
  • Per-tag deprecation columns (deprecated_at, replaced_by_id) on every tag entity per D4.
  • Pose-tracking domainpose_engines + pose_landmarks reference 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_overrides table 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 TEXT with condition_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_id already 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/frames ingest 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 is mkdir audio/{lang}/ + uploading 15 VO files per exercise + updating manifest.languages.

Operational

  • Bunny credentials via Cat A providers resolver — env vars today, platform_service_providers row + 5-minute-TTL resolver before production launch. Requires hoisting services/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