Skip to main content

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 timestamp t. This is the existing live overlay made deterministic (driven by t, not a playing <video>.currentTime). Reuse rep-feedback-shared / the recording overlay; factor the presentation into @enode/ui like the connect-sensor / sidebar extractions.
    • valueAt(series, t) — step/interpolate the metric series; bar path = points with sample.t ≤ t. The data is already persisted per rep (raw data + the firstFrameDate sync 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

TargetDecode + encodeNotes
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)NativeWebCodecs 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.
Serverheadless Chromium runs the shared renderer + ffmpegOne 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 AVVideoCompositionCoreAnimationTool wants 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

  1. 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.
  2. Portal WebCodecs export as the proof — runs entirely in the browser, produces a real mp4 to eyeball the look before committing to mobile/server.
  3. 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.