Video Rendering: Strategy & Economics
The locked architectural decisions for how exercise videos are rendered, stored, and delivered — and the measured storage/egress numbers that justify them. Companion to composition.md (the compositional model) and cue-manifest.md (the overlay sidecar).
Why this doc exists
The "should we bake one flat video, layer at runtime, or split into a playlist?" question recurs. It is settled: we bake monolithic flat MP4s. This doc records that decision, the reasoning (driven by our patient audience, not by storage cost), and the numbers measured against Bunny on 2026-05-27 so nobody re-litigates it from intuition.
The audience is the architecture
Our patients are mostly older people on older, low-end devices (phones, tablets, smart TVs) on variable clinic/home networks. Every rendering decision flows from that single constraint. Two things weak devices do badly:
- Runtime media-track compositing — mixing a separate audio track over video, or stacking video layers, synced at playback time.
- Gapless multi-segment playback — stitching N short clips into one seamless experience; each boundary is a fresh fetch + decode start + quality ramp.
A single flat MP4 asks for neither. The device does the one thing it does reliably: play one file.
Locked decisions
D1 — Monolithic baked flat MP4 (video + VO flattened in)
Each render is one MP4 with the voiceover, breathing wild-track, and all framing baked into a single flat video + single audio track. Not layered at runtime; not split into a playlist.
- Why not runtime layering: weak devices choke on audio/video track sync. We do the mixing once, at bake time, on our infra. See composition.md (the multi-primitive audio-bundle model was abandoned after cross-device testing).
- Why not a segment playlist: splitting into reusable building-block segments (intro+set / pause+set / outro) looks like it cuts render count, but: (a) every segment boundary is a buffering + quality-dip moment on exactly the devices we care about, and (b) renders are lazy + content-addressed + reused (
EnsureRenderdedupes on(exercise_id, recipe_hash, language)), so the combinatorial recipe space never materializes — you only bake recipes actually prescribed, each once, cached forever. The render-count argument for splitting is therefore moot, and the playback cost is real. We bake whole. - The combinatorial math (recorded so it isn't re-derived): for a bilateral exercise across {just-L, just-R, L→R, R→L} × reps {5,10,15,20}, full = 40 distinct videos if reps may differ per side (16 if locked symmetric); the best fused split = 24, fully-decomposed = 13 — but split only wins on render count, which lazy caching already neutralises, while losing on playback smoothness. Split crossover favours full whenever dosing is symmetric or capped at ≤2 sets.
D2 — Keep both HLS and progressive MP4 copies
Bunny stores the renditions twice: HLS (H264) for adaptive streaming + progressive MP4 as the fallback. We keep both. The progressive MP4 is the old-device-safe path (no hls.js, no ABR); HLS serves the patients on capable devices/networks. Storing both costs storage, not egress (a play streams one path). Storage is cheap enough (below) that the redundancy is not worth removing.
D3 — Resolutions: 240/360/480 always, 720p enabled; nothing above 720p
The served ladder is 240p / 360p / 480p / 720p. No 1080p+ delivery — our floor device tops out around 480p effective, and 720p is the egress swing factor (see below). 720p stays enabled for the slice of patients on decent screens; Bunny picks the rung adaptively per device.
D4 — One flow, per-segment graceful degradation (resolved)
There is one render flow. Each framing segment independently picks the best asset it has — motion video → freeze-frame → skip — so a render can be standard-intro + static-pause + skipped-outro, decided purely by asset presence (no manifest flag). The static-framing primitives (freeze-frame = the upcoming set's first frame; black-screen outro) were prototyped 2026-05-27 (services/media/internal/composer/compose_static.go, preview via cmd/compose-local). The full asset taxonomy and the cascade are in Asset taxonomy & the degradation cascade below.
D5 — kind is the production render model
kind answers how the video is produced, not only how it's dosed. Three values:
reps_based— composed, rep-counted (intro → sets → pause → outro). Many cached renders per recipe.duration_based— composed, timed holds: the identical framing cascade, but the work segment is a held position dosed in holds rather than a rep block. Many cached renders per recipe.static— one pre-baked MP4: no primitives, no composition, no cues, one render per language. A legitimate ongoing category — not "legacy"; new static videos are allowed any time.
Every kind goes through the media service; only the composed two go through compose. static takes a non-compose ingest path (upload to Bunny Stream + bake the TV Constrained Baseline rungs + thumbnail).
Exercises evolve in place. A static exercise re-filmed with primitives flips kind static → reps_based | duration_based on the same row (same slug, same taxonomy) and starts composing; the static render is kept for rollback, and kind is the active-lane switch (static → serve the static render; composed → serve recipe renders). The re-film backlog is WHERE kind = 'static'. This is why static is a kind value and not a side flag: a static video's eventual reps-vs-duration nature is undetermined until it's re-filmed, so it can't honestly carry a composed kind early. Detail in Render models in detail.
Not a layering decision: cue overlays stay
"No runtime layering" means no media-track compositing. It does not touch the patient-client HTML/DOM overlays (rep counter, set badge, rest countdown) drawn on top of the flat video via video.currentTime × the cue manifest. Those are near-zero cost and run fine on ancient devices. Flat baked MP4 + lightweight DOM overlays is the intended split of labor.
Storage economics (measured 2026-05-27)
Bunny stores four things per uploaded video: HLS renditions (H264), progressive MP4 fallbacks (MP4), the verbatim Original upload, and Other (playlists/thumbnails/meta). Bunny encodes capped-VBR against a fixed ladder — 240p 600 kbps · 360p 800 kbps · 480p 1400 kbps · 720p 2800 kbps — so compressible exercise footage uses well under the ceiling, but the ladder is the cap.
Ground-truth samples:
| Sample | Config | H264 | MP4 | Original | Other | Total |
|---|---|---|---|---|---|---|
lumbar-detensioning L/5→R/5 (~50s) | 480p max | 16 MB | 13 MB | 18 MB | 3 MB | 48 MB |
clamshell L/10→R/10 (121s) | 720p max | 39 MB | 34 MB | 25 MB | 2 MB | 101 MB |
Note clamshell's Original (25 MB) equals the baked master we uploaded — Bunny keeps it verbatim. Adding 720p doubles the renditions (720p's 2800 kbps = 240+360+480 combined) but only ~1.6× the total, because Original+Other are fixed.
Library projection (5 prescribed recipes/exercise × 1000 exercises; ranges = naive ×5 at the long sample vs realistic ~95s/recipe mix):
| Config | Per recipe | Library (×5 ×1000) |
|---|---|---|
| 720p, all copies (as measured) | ~101 MB | ~400–500 GB |
| 480p max, all copies | ~63 MB | ~250–320 GB |
| 720p, Original dropped | ~76 MB | ~300–380 GB |
| 720p, MP4-only (drop HLS) + Original dropped | ~36 MB | ~140–180 GB |
Decision: ~500 GB is negligible (~$3–5/mo Bunny edge storage). We keep all copies (D2) and don't bother dropping the Original. Storage is not a constraint; optimize for playback, not footprint.
Egress economics (the number that actually scales)
A play streams one rendition, one copy for the duration watched — not the 101 MB at rest. Keeping both HLS+MP4 (D2) does not double egress. Per-rung play size, derived from the measured 39 MB HLS ladder, scaled to a ~95s average exercise:
| Streamed rung | Per play (~95s) |
|---|---|
| 360p | ~4.5 MB |
| 480p | ~7.5 MB |
| 720p | ~15 MB |
Model: a daily session = 10 exercises. Monthly = play size × 10 × 30. Central case 480p ≈ 7.5 MB/play → 2.25 GB/user/month.
| Streamed rung | /user/mo | 100 users | 1,000 | 10,000 |
|---|---|---|---|---|
| 360p | 1.35 GB | 135 GB | 1.35 TB | 13.5 TB |
| 480p | 2.25 GB | 225 GB | 2.25 TB | 22.5 TB |
| 720p | 4.5 GB | 450 GB | 4.5 TB | 45 TB |
Cost (Bunny CDN ~$0.01/GB, EU/NA):
| rung | 100 | 1,000 | 10,000 |
|---|---|---|---|
| 360p | ~$1.35 | ~$14 | ~$135 |
| 480p | ~$2.25 | ~$23 | ~$225 |
| 720p | ~$4.50 | ~$45 | ~$450 |
So 10,000 daily-active users at 480p ≈ 22.5 TB/month ≈ $225/month. Egress is not a cost problem at any realistic scale.
Caveats baked into the numbers:
- Assumes every active user completes a 10-exercise session every day for 30 days — a hard upper bound. Real rehab adherence (~3–5 days/week) puts actual egress at ~50–70% of the table.
- ABR + start-buffer overhead adds ~10–20% real-world.
- 720p is the only knob worth governing — it roughly doubles egress vs 480p. Worth watching what fraction of devices climb to it.
Asset taxonomy & the degradation cascade
Source assets per exercise
Two groups: a standalone instructions clip, and the baked framing + rep slots that compose into the recipe MP4.
Standalone — not baked into the recipe MP4:
instructions— one take, side-neutral, VO per language, wild track muted. Played before the exercise or as an overlay. Educational ("how to do this exercise"), reps- and recipe-independent — distinct from the bakedintro, which is the short per-render lead-in to set 1's position. No-Nvariants (one take; only the VO is extended per language). This cleanly replaces the oldintro-with-instructionsslot.
Reps are one axis (always present — the floor): reps for unilateral exercises, reps-left / reps-right for bilateral. 5-rep block, looped to the dose; VO master = 20. Keeps its breathing wild track mixed under the VO.
Framing is a separate axis with two models, and a bundle ships exactly one of them (never both — see the integrity rule). All framing clips carry -N variants and a per-language VO, VO-only (wild track muted, per framingFilterComplex):
| Framing model | Slots | Used by |
|---|---|---|
| Shared-position (generic) | intro, pause, outro | unilateral exercises and shared-home bilateral ones (e.g. neck rotation — returns to a common forward position between movements) |
| Distinct-position (sided) | intro-left, intro-right, pause-left (L→L), pause-right (R→R), pause-left-to-right (L→R), pause-right-to-left (R→L), outro-left, outro-right | bilateral exercises whose sides are distinct positions (e.g. clamshell — left-lying vs right-lying needs a visible roll) |
So a shared bilateral exercise has bilateral reps but generic framing, while a distinct bilateral exercise has sided framing for everything. Bare necessities (what guarantees some render) are just reps + rep VOs + manifest.
Teleport risk is a property of the exercise's framing model
The body holds a physical position at every segment boundary. Whether a left↔right change needs a visible transition depends on the exercise, not on the recipe alone:
- The recipe says when a side-change happens (set L → set R).
- The bundle's framing slots say whether that change needs a transition. A shared-position exercise returns to a common home pose between movements, so the single generic
pausehandles the L↔R alternation seamlessly (no reposition). A distinct-position exercise needs the directional pause (*-to-*) to show the body moving from one side to the other; a generic pause there would teleport — but a distinct bundle never ships a generic pause to fall back to.
The model is inferred from which framing set the bundle ships — no manifest flag. Integrity rule: a bundle ships one framing set, never both. At the bare floor (no framing at all), every segment freezes, which is safe under either model, so the floor needs no disambiguation.
The per-segment cascade (first available wins) — model-aware
Shared-position bundle (generic framing):
| Segment | Cascade |
|---|---|
| Intro | intro → freeze(rep-[set 1 side] frame 0) → skip |
| Pause (any transition, incl. L↔R) | pause → freeze(rep-[next side] frame 0) → skip |
| Outro | outro → black → skip |
Distinct-position bundle (sided framing):
| Segment / transition | Cascade |
|---|---|
| Intro → set 1 (side X) | intro-X → freeze(rep-X frame 0) → skip |
| Pause, same side X→X | pause-X → freeze(rep-X frame 0) → skip |
| Pause, side-switch X→Y | pause-X-to-Y → freeze(rep-Y frame 0) → skip |
| Outro ← last set (side X) | outro-X → black → skip |
Decided independently per segment by asset presence. There is no cross-model fallback (a distinct bundle has no generic framing to drop to), so the generic rung never produces a teleport — it only exists where the exercise genuinely returns to a shared home.
Invariants
- VO drives duration. When no motion video is used, the segment's runtime is its VO's duration. No VO → skip the segment — we never fabricate timing.
- Freeze-frame can't teleport — it's the upcoming rep's own first frame — so it's the universal safe rung.
- Skipped segment → omit its cue from the manifest (not a zero-duration cue), so the patient client doesn't flash a 0ms intro/rest/outro overlay. (
walkCueschange.) - Loop-closure (filming requirement): boundary frames must match —
intro-Xend ≈reps-Xstart;reps-Xstart ≈ end (clean loop) ≈ the next segment's start. Without it the scheme teleports at the loop seam regardless of framing. This is the load-bearing constraint the whole sided-framing investment rests on.
Provisioning tiers
| Tier | Ships | Result |
|---|---|---|
| Floor | reps + rep VOs + manifest | Renders, but framing segments skip (no VO → no segment) → teleports on side-switches |
| Recommended | + framing VOs (audio only, cheap — generic for shared-position, sided for distinct) | No teleport ever (freeze floor on every segment) |
| Full | + framing motion videos (generic or sided per the exercise's model) | Smooth motion framing; freeze as automatic per-slot fallback |
Guidance to the filming/audio team: ship the framing VOs first — that one cheap audio asset moves off the teleport floor onto the freeze floor everywhere, including switches. The motion videos are pure upgrade, added slot-by-slot as filmed.
Filmmaker delivery & sync-time pre-bake
Validated on real footage 2026-05-28
The recipe below was validated end-to-end on weighted-saw (Fierastraul cu greutate) — L-to-L, L-to-R, R-to-L renders all play cleanly through the cascade. The constituent-delivery model is the supported format for filmmaker hand-off going forward.
Filmmakers' natural production unit is transition primitives, not pre-baked canonical pauses. The asset bundle we upload to s3://{bucket}/platform/{slug}/ is fully canonical (the composer never sees raw delivery), but getting there is a pre-bake step on our side — not extra work for the filming team.
What filmmakers deliver (constituent model)
Per side (sided bilateral example):
| Filmmaker name (Romanian, varies) | Role | Reused for |
|---|---|---|
Intro {side} | enter into the position from neutral | intro-{side} slot + the tail of every pause that ends on that side |
iesire {side} | exit from the position back to neutral | the head of every pause that starts on that side |
pauza (one richer take) | neutral pause | the middle of every pause |
final {side} | outro proper (end-of-exercise closing) | outro-{side} slot |
Video 1-5 v{N} {side} | rep block(s) | rep-{side}-video-{N} |
Instructiuni | instructions clip | standalone instructions |
| VOs per slot (per language) | matched audio tracks | same slot mapping |
This is 3 transition clips per side + 1 neutral pause + rep blocks + final + instructions — about 3 video clips per side instead of the 5 baked pause variants the cascade slot taxonomy would otherwise require.
Pre-bake recipe (the team's prep before aws s3 sync)
The 4 sided pause slots the cascade reads are concat'd from the constituents — every pause is iesire-from-side + neutral pauza + intro-to-side:
| Cascade slot | Constituent recipe |
|---|---|
pause-{side}-video-1.mp4 (L→L, R→R) | iesire-{side} + pauza + intro-{side} |
pause-left-to-right-video-1.mp4 | iesire-left + pauza + intro-right |
pause-right-to-left-video-1.mp4 | iesire-right + pauza + intro-left |
Audio mirrors video — every pause-VO is the matching 3-segment audio concat, which produces a more narratively coherent "exit / rest / enter" track than a monolithic single-take VO would.
The video bake normalises to 1920×1080 30fps yuv420p (libx264 CRF 20, faststart), and the audio bake resamples to 48 kHz stereo WAV — uniform input regardless of what the filmmakers' camera/codec was. The composer normalises again at bake time, so the pre-bake doesn't have to match the composer's pipeline exactly; it just needs the constituents fused into single files the cascade can read.
A worked example (the four pause-bake ffmpeg invocations used on weighted-saw) lives in reference/exercise-content-pipeline.md → constituent pre-bake. Same pass handles the canonical rename + the bundle's manifest.json.
Status — manual today, automation deferred
The pre-bake is currently a manual bash + ffmpeg pass per exercise. A proper sync-time automation — a script, a make ingest-bundle target, or an extension to the media service's S3-sync flow that consumes filmmaker delivery + a small declarative recipe and materialises the canonical bundle — is a tracked follow-up, not built. Every constituent-format exercise needs the manual prep until then.
Why this is the model (not "ship canonical pauses")
- Filming/editing efficiency: ~3 clips/side instead of 5 (and the savings compound on the audio side).
- More natural audio: the 3-segment side-switch VO matches what's visually happening; a monolithic VO would feel artificial.
- Constituents are reusable: the same
iesire-leftclip is the head of bothpause-left(L→L) andpause-left-to-right(L→R), so any unevenness in performance from take to take doesn't compound across slots.
One filming constraint that follows
The neutral pauza clip's start frame must match every iesire-{side} end frame, and its end frame must match every intro-{side} start frame — i.e. the filmmakers' definition of "neutral" must be consistent across all takes. In practice this falls out of one continuous shooting session with the actor on a clean mark. If a bundle's pause shows a visible jump at either seam in QA, that's a re-shoot, not a pipeline fix.
Render models in detail
Shipped (2026-05-27)
D5's static / timed-hold duration_based split is implemented end-to-end (media + API + migration + openapi). See Build status.
Timed-hold duration_based
A duration_based exercise is dosed in holds, not reps — "hold this position, N times." The composer treats it as reps_based with a block of 1, so it reuses the existing bake almost verbatim:
- One
rep-videoclip = one hold (e.g. a ~10s squeeze plus its get-into-position lead-in and release).holds_per_video_block = 1. - The
rep-vomaster coaches up tomax_holdsholds (thecounts_per_audio_masteranalogue). A dose of K holds stream-loops the clip K times and trims the VO to the visual duration — this isbakeSetwithrepsPerBlock = 1. - The framing cascade (intro → hold → pause → hold → … → outro) is unchanged — generic or sided, motion → freeze → skip, identical to
reps_based. - The recipe's per-set count is the hold count, and the work cue carries holds. (The patient client shows a hold timer rather than a rep counter — a downstream player concern, not this service.)
hold_seconds(renamed fromduration_seconds) is display metadata — the per-hold length shown in the dose UI; it does not drive the bake.
Validation differs from reps_based only in the count rule: reps_based requires "multiple of block in [block, master]"; duration_based requires "holds in [1, max_holds]". laterality is allowed (a bilateral hold is possible), and the floor + one-framing-set integrity checks run identically.
Filming constraint (load-bearing): the hold VO must be self-contained when trimmed to a single hold — a 1-hold dose uses only the first hold's worth of the master, so that cut can't land mid-instruction. (scapular-squeeze-supine ships a 36.7 s rep-vo over a ~18 s clip ≈ 2 holds.) This is the timed-hold analogue of the rep-tempo-locked constraint.
The bundle manifest swaps the reps mechanics for the hold contract:
{
"manifest_version": 1,
"exercise": "scapular-squeeze-supine",
"kind": "duration_based",
"languages": ["ro"],
"laterality": "unilateral",
"hold_seconds": 10,
"holds_per_video_block": 1,
"max_holds": 2
}static ingest
A static exercise ships a minimal S3 bundle on the same delivery path as every other kind — no separate upload endpoint:
{ "manifest_version": 1, "exercise": "<slug>", "kind": "static", "languages": ["ro"] }plus the finished per-language video(s) in the bundle root (static-video-{lang}.mp4; audio is already baked in). The media service ingests it (no compose):
- Normalize the source to a canonical master (same geometry + AAC-LC 48 kHz stereo as a composed render's master, faststart) so Bunny's HLS transcode and the baseline bake see uniform input regardless of the filmmakers' source codec/SAR/resolution.
- Upload that master to Bunny Stream (HLS ladder; Bunny auto-generates the catalog poster).
- Bake the two TV Constrained Baseline rungs off it (baseline.go, fed the ingested master instead of a compose master) → its own per-slug subdir
platform/exercises/{slug}/static/{lang}.{sha}.{rung}.mp4(sibling torenders/,instructions/; kept out ofrenders/so the recipe-render reconciler — which matchesrenders/byrecipe_hash[:12]— never sees a static rung as an orphan).
No recipe, and no cues — a static video has no rep/set/hold structure to map. It does, however, emit a degenerate cue manifest (cues: [], just the Bunny Stream video pointers + duration) at platform/exercises/{slug}/cues/_static.{sha12}.json: the Portal session conductor is manifest-driven and the run requires a manifest_url on every exercise in a session (isSessionRenderable), so a manifest-less static exercise would block the whole session. The empty-cue manifest plays straight through (no overlays — currentCue stays null) and advances on the video's ended event. The cue-manifest URL is also stored on the static/ reconcile sidecar (cue_manifest_url) so reconcile re-attaches manifest_url after a DB wipe without a re-ingest. The Bunny auto-thumbnail serves as the catalog poster (no custom loop+poster bundle). API persists it as a single _static render row per language with the video_id + manifest_url + TV URLs. The asset_version bump flow works identically. Manifest.Validate for static requires only kind + languages (no laterality/mechanics fields); IntegrityCheck requires the per-language static-video-{lang}.mp4. Endpoint: POST /v1/exercises/static.
Not every single-MP4 belongs as a static exercise
static wraps an MP4 as a prescribable exercise — it lives in the exercise catalog, is dose-able (as video_only), is browsable by patients alongside reps/duration exercises. A pre-recorded full session (e.g. a 47-minute guided programme) or an educational clip is a different beast: it has no exercise semantics, doesn't go through the session-run lifecycle, and shouldn't drag the session-engagement machinery (overlays, TV companion, cues) into a passive-watching use case. Those land in a separate content type (working name educational_videos) — see features/educational-videos/. Design noted, not built.
Manifest (v1, reset)
The bundle manifest resets to manifest_version: 1 for this asset model — all bundles are wiped and re-uploaded (early dev, no production data), so the old v2 schema is gone and starting fresh at 1 is functionally clean. ⚠️ Do not confuse this with the cue manifest's schema_version: 1 (cuemanifest.go) — that is an unrelated, patient-contract schema in a different file; the two "v1"s are independent. Because the cascade degrades on missing assets instead of failing, the manifest no longer declares asset presence — scanVariants discovers it. So has_pause and has_instructions are dropped (discover-don't-declare), and there is no side-switch flag (the framing model is inferred from which framing slots exist). The manifest shrinks to capability + bake mechanics:
{
"manifest_version": 1,
"exercise": "clamshell",
"kind": "reps_based",
"languages": ["ro", "en"],
"laterality": "bilateral",
"reps_per_video_block": 5,
"counts_per_audio_master": 20
}(duration_based swaps the reps_* fields for the hold contract — hold_seconds + holds_per_video_block + max_holds, plus laterality — and static carries only kind + languages; both are shown in Render models in detail.) The integrity check shifts from "all-or-nothing, fail if declared-but-missing" to floor required (reps + rep VOs + manifest), everything else a tier report — plus one hard rule: a bundle must ship exactly one framing set (generic or sided), never both.
Two pause concepts — don't conflate them
There are two independent "pause" layers; only the first touches the render:
- Baked pause (render layer). Not a recipe field — the composer auto-inserts a pause between sets, gated today by
manifest.has_pause && len(sets) > 1(compose.go), duration fixed by the asset (pause video in standard, pause VO in static). The cue kind (rest_between_sets/rest_side_switch) is derived by comparing adjacent set sides inwalkCues. Under the new cascade the gate changes fromhas_pauseto "pause asset present → bake, else freeze, else skip." - Configured rest (session layer). The session-exercise carries
rest_between_sets_s/rest_after_exercise_s(sessions/handler.go) — clinician-set rest in seconds, surfaced to the patient client as a countdown overlay. These are never sent to the media service and do not affect the MP4. How the overlay composes with the baked pause is a patient-client concern.
Standalone instructions render
instructions is rendered separately from the recipe MP4 (it's per-(exercise, language), not per-recipe; one take, no -N variants; video + VO baked, wild track muted; no cue manifest — no reps/sets/cues). The pipeline:
- Bake one MP4 per language from
instructions-video+instructions-vo-{lang}. - Upload to Bunny Stream in the same collection as the exercise's recipe renders, as a separate video.
- TV baseline rungs (Constrained Baseline 480p/720p, same as recipe renders) → Bunny Storage Zone, in a separate folder:
platform/exercises/{slug}/instructions/(sibling to the recipe renders'…/renders/).
Closest existing shape is the thumbnail endpoint (per-exercise, on-demand) — likely its own endpoint rather than part of /compose.
Build status
Media service — shipped. manifest v1 + slot-keyed scanVariants (sided/directional/instructions slots) + floor & one-framing-set IntegrityCheck; single model-aware Compose cascade (the separate ComposeStaticFraming is gone) with the cue timeline built inline (skipped segments emit no cue); standalone instructions render (ComposeInstructions + POST /v1/exercises/instructions → Bunny Stream same-collection + TV baseline rungs to …/instructions/). Verified against real footage for the motion / freeze / black / skip rungs (make check + make test green).
Render-model split — shipped (2026-05-27). D5's three-kind model is implemented:
- DB —
exercises.kindCHECK + the Go enum gainedstatic(in-place edit to migration000022, pre-first-deploy;kindreframed as the render model). - Media —
duration_basedcomposes (Manifest.Validateallowslaterality+ hold fields,IntegrityCheckfloor runs,validateRecipesources block/max via accessors so holds-in-[1,max_holds]falls out,ComposerunsrepsPerBlock = blockSize(), the work cue carries holds);POST /v1/exercises/staticingest (Bunny Stream + baseline rungs to…/static/, no compose/cues); the catalog thumbnail bake now acceptsduration_based. - API —
Createscaffolds a draft for every kind (GUID-paste import removed);Publishcomposes the preview + thumbnail for composed kinds and ingests per-language_staticrenders for static;EnsureRenderis composed-only; the_importedsentinel was renamed_static; console create/list/detail surfaces handle all three kinds.
The framing cascade and reps_based are untouched. Remaining UX follow-up: the clinic session-builder picker is still gated to reps_based — the dose dialog's holds (duration_based) / no-dose (static) UX is not yet built, so those kinds aren't yet prescribable in a session.
API alignment — shipped
The exercises table is a faithful mirror of the manifest, so since the manifest no longer declares has_pause / has_instructions, both were dropped from the mirror (migration 000022) — not re-sourced. The semantic consequence is now live: a bilateral exercise can always alternate. The dose dialog offers all four side options for bilateral exercises unconditionally, and the session-compatibility check no longer gates alternating_* on a pause flag — the framing cascade produces the side-switch from whatever pause/freeze assets exist. has_instructions was vestigial (carried in the clinic lookup type, never used) and instructions are universal anyway, so it was dropped outright; if an "instructions available" signal is ever needed it comes from render state (does an instructions clip exist), not the manifest mirror. Touched: media client struct, exercises + sessions domains, migration 000022, openapi.yaml (+ regenerated Go/TS types), clinic dose dialog / session-builder / exercises-lookup, the data-classification registry. manifest_version default is now 1.