Set video export (shareable rendered clip)
A shareable video that stacks the recorded set clip in a top rect with an animated metrics panel below (velocity / acceleration / power / force + peak labels, bar path, scrub bar, branding), all synced to playback. See the four reference mockups in the chat history / design — those are the spec.
Status (2026‑06‑20): plan only, nothing built. This doc is the source of
truth + handoff. Capture is already solved — see
video-recording.md; this is the export/render side.
1. Guiding principle — separate rendering from encoding
The export is produced frame‑by‑frame off a synthetic clock, never by
screen‑recording a live playback. For frame i: t = i / fps, draw the whole
composite at exactly t, hand the frame to an encoder. No MediaRecorder, no
dropped frames, no audio drift — deterministic and frame‑accurate. This is the
single most important decision for a stable result.
2. The clean split
[decode source frame @t] → [SHARED: compose video+panel, draw metrics @t] → [encode frame]
seam (platform) one TS module, deterministic seam (platform)
- Shared middle (the bulk, ~80–90%) — a pure, deterministic renderer reused
by portal, the Capacitor app, and any server path:
renderExportFrame(canvas, t, model)— draws the scene from the rep time‑series at timestampt. This is the existing live overlay made deterministic (driven byt, not a playing<video>.currentTime). Reuserep-feedback-shared/ the recording overlay; factor the presentation into@enode/uilike the connect-sensor / sidebar extractions.valueAt(series, t)— step/interpolate the metric series; bar path = points withsample.t ≤ t. The data is already persisted per rep (raw data + thefirstFrameDatesync anchor; see video-recording.md §1).- the fixed‑fps stepping loop.
- Two thin seams (platform‑specific): decode the source clip to a frame at
t, and encode frames → mp4 (H.264, optional original audio).
Suggested interfaces:
interface FrameSource { frameAt(t: number): Promise<VideoFrame | ImageBitmap>; }
interface VideoExporter { add(frame: CanvasImageSource, t: number): void; finish(): Promise<Blob | string>; }
Export onto a fixed canvas (e.g. 1080×1920 portrait): source video scaled object‑cover into the top rect, panel below.
3. Where the seams run
| Target | Decode + encode | Notes |
|---|---|---|
| Portal (desktop) | WebCodecs (VideoDecoder/<video>+requestVideoFrameCallback, VideoEncoder + mp4-muxer) | Solid in Chromium/Safari 17+. Reuses the shared renderer 1:1. Best first target. |
| Mobile (Capacitor) | Native | WebCodecs in the iOS WKWebView is too unreliable to depend on. Cheapest split: render only the panel with the shared TS renderer, hand those frames to a Capacitor plugin that composites over the source clip + encodes natively (iOS AVAssetWriter / AVVideoCompositionCoreAnimationTool; Android MediaCodec+GL). Reuse the existing native video plumbing from recording. |
| Server | headless Chromium runs the shared renderer + ffmpeg | One code path for everyone — app & portal just POST /sets/{id}/export and poll. Most consistent pixels, no codec‑quirk fights. |
4. Where to render — the one decision
- Server‑side (recommended if infra is acceptable): truly one shared code
path; least client code; consistent everywhere. Backend is Swift Vapor —
on Linux Vapor owns the endpoint + a background job queue and shells out
to Chromium + ffmpeg (the rendering is not Swift; a Node worker is an
equally natural home for that toolchain). On macOS a pure‑Swift
AVFoundation renderer is possible and would share the iOS compositor, but
AVFoundation is Apple‑only (no Linux) and
AVVideoCompositionCoreAnimationToolwants a logged‑in GUI session on a headless Mac — only worth it with a Mac render node. Never render in the request handler — heavy; use a worker + queue, cache output by set id, pull/push the clip from storage. - On‑device / offline: shared renderer + WebCodecs (portal) + one Capacitor plugin for mobile encode. The renderer stays shared; only the encode seam is native. Medium cost (one plugin covering iOS + Android).
5. Build order
- Shared deterministic renderer in
@enode/ui(renderExportFrame+valueAt+ the frame loop), refactored out of the live overlay. Keystone — needed by every option, reusable, low risk. - Portal WebCodecs export as the proof — runs entirely in the browser, produces a real mp4 to eyeball the look before committing to mobile/server.
- Then either the server worker (Vapor endpoint + queue + Chromium/ffmpeg) or the native Capacitor exporter, per the §4 decision.
6. Open decisions / TODO
- TODO: choose server‑side vs on‑device (§4) — gates steps 3+.
- TODO: confirm export dimensions / aspect + how the source clip is letterboxed vs cropped into the top rect.
- TODO: keep original audio in the export, or silent?
- TODO: which surface(s) trigger export (technique tab review? a share button?) and whether export is per‑rep or per‑set.