Files
AV-Live/docs/superpowers/plans/2026-05-18-avlivebody-macos-rewrite.md
T
Clément SAILLANT 5c61112826 docs: add AGENTS.md skeleton (#1)
* docs(plans): action-head v3 + branch sync notes

Update plan header :
- v2 (Task 18) + v3 (Task 19) extensions chronology
- Studio train validated, ckpt action_head_v3.pt landed
- Mesh NaN-guard debug trail (commit 4e7101c)
- Branch convergence main == feat/action-head
- Pointers to memories project_action_head_v3, etc.

* feat(av-live): openpos 3D + DINO reid + filter

Three improvements wired end-to-end:

1. Openpos 3D skeleton visible: Skeleton3DRenderer attached to a
   RealityKit AnchorEntity in BodyView, toggled by showSkeleton
   or vizMode==9. PoseOSCListener now parses /pose3d/count and
   /pose3d/kp (plus restored /face/* and /hand/* paths).

2. DINO re-id (dinov2_vits14, ~9 ms ANE forward):
   MeshRigger combines Hungarian IoU with cosine similarity over
   a per-pid embedding history (deque maxlen=10), weighted by
   MULTIHMR_REID_ALPHA (default 0.5). Falls back to pure IoU if
   DINO mlpackage absent or scipy missing. state.last_frame_rgb
   buffer added so the rigger can crop bbox regions for embedding.

3. PoseFilterChain on pose_world_landmarks:
   median (anti-spike) -> Kalman constant-velocity ->
   50 ms lookahead -> IK elbow/knee/ankle clamp. Configurable
   via POSE_FILTER env (default median+kalman+lookahead+ik).
   <2 ms per frame for typical 1-2 persons.

Tests: 5 new in test_dino_reid.py + 6 new in test_pose_filter.py,
all green. Live validated by user: skeleton spawns, mesh stays
stable.

* fix(av-live-body): restore face+hand+3D (f540158)

Three regressions after recent merges, all restored to match the
original f540158 design:

1. FaceHandOverlay was no longer instantiated in ContentView.
   Added back as a SwiftUI Canvas overlay (68 dlib face landmarks
   with mouth slots 48-67, plus 21x2 hand landmarks cyan/magenta).

2. Skeleton3DRenderer was not attached. BodyView now creates an
   AnchorEntity at (0,0,-2.5), instantiates Skeleton3DRenderer
   and ties its visibility to vizMode==9 or showSkeleton toggle.

3. Joint and bone radii bumped to 4.5 cm / 2.2 cm so the 3D
   skeleton actually reads as 3D instead of looking flat.

MeshRenderer exposes pelvisWorld map per pid for future
interconnect uses (not auto-applied -- design keeps mesh and
skeleton each in their own coord space per f540158).

* feat(av-live): wireframe skel + face/hand filter

Skeleton3DRenderer now renders a wireframe: joint radius 1 mm
(quasi-invisible), bone radius 3 mm (line-like). Replaces the
chunky bead armature with a clean filaire silhouette covering
body 33 joints + face 68 dlib + hands 21x2, all 3D.

FaceHandOverlay 2D Canvas removed from ContentView -- face and
hand landmarks now live in the same 3D RealityKit armature as
the body skeleton (Skeleton3DRenderer.applyFace / applyHands,
anchored on nose joint 0 + wrist joints 15/16).

pose_filter.py extended with FaceFilterChain (alpha-beta + 30 ms
lookahead) and HandFilterChain. multi.py wires them after the
2D smoothers, plus ghost rejection (POSE_GHOST_MIN_VISIBLE),
bbox NMS (POSE_NMS_IOU), and pid hysteresis. 10 new tests, all
green.

CoreML perf audit (bench_multihmr_coreml.py): predict() = 99% of
wall-time on FP32. ANE catastrophic for DINOv2 (1300 ms),
INT8 weight quant = no live gain (GPU compute-bound).
6.4-6.8 fps live is the hardware ceiling on this model.
quantize_multihmr_int8.py left in scripts/ for future trials.

* deps(icp): add open3d optional extra + smoke test

Context: Task 1 of the ICP LiDAR <-> SMPL-X fusion plan needs a
point-cloud library to align iPhone LiDAR scans with Multi-HMR
SMPL-X meshes. Open3D's CPU-only ICP is sufficient at the 5-10 Hz
LiDAR cadence.

Approach: Add a dedicated `lidar` optional-dep group so the heavy
dependency stays opt-in. Pin Python to 3.12 implicitly via the
regenerated uv.lock because open3d 0.18-0.19 only ships cp311/cp312
wheels (cp314 absent). Smoke test guards future regressions.

Changes:
- pyproject.toml: new `lidar` extra with `open3d>=0.18,<0.20`
- uv.lock: regenerated with open3d 0.19 + transitive deps
  (scikit-learn, scipy, dash stack, etc.)
- tests/test_open3d_smoke.py: two-test smoke suite
  (PointCloud roundtrip + ICP convergence on translated copy),
  gated by `pytest.importorskip("open3d")`

Impact: Unlocks subsequent ICP fusion tasks (LiDAR ingest, mesh
alignment, transform publication) without forcing open3d on
contributors who only run the base pose pipeline.

* feat(icp): LiDAR TCP frame decoder + tests

* feat(icp): LiDAR TCP socket reader with reconnect

* feat(icp): extrinsic dataclass + JSON persistence

* feat(icp): Kabsch + calibration CLI scaffold

* feat(state): persons_arkit_joints fields

* feat(viz): ARKit 91 -> MP 33 joint map

* feat(viz): iphone OSC listener :57128

* feat(viz): arkit_fuse stage overrides 14 slots

* feat(viz): arkit pelvis z locks cam translation

* feat(viz): iphone OSC listener auto-start

* docs: arkit fusion env vars

* feat(icp): point-to-plane register + reject gate

* feat(icp): partition LiDAR per pid by max-dist

* feat(icp): FusionWorker + State.lidar_points

* feat(icp): wire fusion thread behind ICP_FUSION

Task 9 of the ICP LiDAR plan: integrate the FusionWorker built in
earlier tasks into the live data_only_viz pipeline without
disturbing the existing ARKit pelvis fuse path or the Multi-HMR
worker thread.

A new IcpFusionThread pulls LiDAR frames from LidarTCPReader,
stages them into State, and applies in-place ICP registration on
state.persons_smplx[*].vertices_3d. It runs as a separate daemon
thread parallel to MultiHMRWorker rather than inline per frame —
the autonomous-worker architecture didn't fit the plan's
per-frame call site, so we adapted to a polling thread at 8 Hz.

Activation is opt-in via ICP_FUSION=1 plus ICP_LIDAR_HOST; the
default code path is untouched. Shutdown wired through
applicationWillTerminate_.

MultiHMRWorker.predict_once is added as a documented stub
(NotImplementedError) because the existing PyTorch run loop is
too coupled to the camera and MPS lifecycle for a clean
single-shot extraction. calibrate_lidar.py keeps its placeholder
until a follow-up refactor extracts a pure _infer(rgb) helper.

* test(icp): synthetic latency + convergence bench

* docs(icp): env vars + calibration procedure

* docs(plans): icp lidar mesh + arkit joints

Two complementary fusion plans landed in parallel on 2026-05-14:
- iphone-lidar-multihmr-fusion : ARKit 91 joints -> MP33 fuse stage +
  pelvis z override (already implemented in 7 commits)
- icp-lidar-smplx-fusion : LiDAR mesh point-to-plane ICP onto SMPL-X
  10475 verts (12 tasks executed via subagent-driven-development)

Both paths coexist; joints are sparse+fast (60 Hz), mesh is dense+slow
(5-10 Hz). See docs/ICP_FUSION.md for the integration topology.

* feat(icp): predict_once via CoreML backend

* feat(av-live-body): wire ArkitOSCListener :57129

Receives /body3d/kp from iPhone ARBodyTracker on the diagnostic
port (57129, distinct from Python's 57128 fuse input). Plumbed
through ContentView -> BodyView -> Skeleton3DRenderer so the
ARKit joints can be overlayed alongside Multi-HMR mesh.

* feat(ios): iphone ARBodyTracker swiftpm app

iOS 17+ Swift Package app (.swiftpm) streaming ARKit body
joints via OSC UDP to two destinations:

  :57128 -> data_only_viz/iphone_osc_listener.py
  :57129 -> launcher/AV-Live-Body ArkitOSCListener.swift

Features:
- ARBodyTrackingConfiguration + sceneDepth (LiDAR) when supported
- 91 joints per body, /body3d/kp pid joint_idx x y z
- 30 fps throttle
- SwiftUI UI: Host/Port fields, Start/Stop, live joints-per-second
- Inline OSC encoder (no external dep)

Env mesh (TCP :5500) NOT yet implemented; requires a separate
ARWorldTrackingConfiguration session. ICP fusion path runs on
bench data only until phase 2.

* feat(data-feeds): 10 open-data OSC publisher

* feat(viz): DataFeeds OSC listener + HUD

* chore: gitignore tweaks

* docs: network topology + mDNS hostnames

Add a "Network topology" section to top-level CLAUDE.md doc
the 3-host layout (GrosMac source, Supra-M1 sink via mDNS,
iPhone via Personal Hotspot DHCP).

mDNS is canonical now : AVBODY_HOST and MULTIHMR_REMOTE_HOST
accept hostname.local instead of IPs, so the cluster survives
DHCP rotations on iPhone hotspot (172.20.10.x).

* fix(ios): add NSLocalNetworkUsageDescription

iOS 14+ silently blocks UDP to LAN addresses without this key.
The first time the app tries to send to 192.168.0.159, iOS will
prompt the user to allow Local Network Access; the prompt must
be accepted or the OSC stream never reaches the Mac.

Also adds NSBonjourServices declaring _osc._udp so the system
treats the connection as a recognised service.

* docs: network topology + gitignore hygiene

- CLAUDE.md: add mDNS hostname table (grosmac.local, supra-m1.local,
  iPhone hotspot 172.20.10.x). AVBODY_HOST / MULTIHMR_REMOTE_HOST
  accept hostnames — resilient to DHCP rotation.
- .gitignore: exclude .remember/ tool state and iCloud '* 2'
  collision artifacts.

* feat(ios): ARBody skeleton2D + overlay preview

ARBodySession: publish 2D-projected skeleton snapshot for live
overlay rendering on the iPhone screen alongside the camera feed.
ContentView: SkeletonOverlay drawing on top of the AR view, with
mock T-pose for Xcode previews (useMockBackground, useMockSkeleton).

* docs: iPhone USB body-tracking link design

Brainstormed design for replacing the OSC/network iPhone-Mac
link with a wired USB transport via usbmuxd. iPhone streams
ARKit skeleton + HEVC video; macOS app runs Multi-HMR CoreML
and renders the mesh. Network-free, single native macOS app.

* docs: iPhone USB transport plan (1 of 3)

Bite-sized TDD plan for the network-free USB byte-pipe:
shared AVLiveWire frame format, native usbmux client,
iOS TCP frame server, incremental stream demuxer.

* feat(avlivewire): shared wire package skeleton

* feat(avlivewire): fixed 19-byte frame header codec

Add FrameHeader, a fixed-size binary record so the demuxer can
frame and resync the iPhone USB stream. Layout is big-endian:
4-byte magic AVL1, tag u8, pid i16, timestamp f64, length u32.

The magic prefix lets a reader detect and skip corrupt bytes.
Decoding rejects short buffers and bad magic by returning nil.
Big-endian append/parse helpers are added as Data/UInt extensions
to keep the codec self-contained.

* chore: ignore SwiftPM .build artifacts

Both AVLiveWire and AV-Live-Body produce .build/ on swift
test; ignore them so they never get accidentally staged.

* feat(avlivewire): skeleton and video codecs

Add SkeletonPayload (91 ARKit joints + per-joint validity) and
VideoPayload (one HEVC access unit + keyframe flag) with
big-endian encode/decode. Reuses Task 2 Data/UInt32 helpers.

* feat(avlivewire): incremental stream demuxer

Add StreamDemuxer that accepts arbitrary byte chunks from a
non-frame-aligned stream and emits complete (FrameHeader, Data)
frames, resyncing on the magic prefix after corruption.

* fix(avlivewire): cap demuxer payload length

A corrupt header with a huge UInt32 length made feed buffer
forever waiting for bytes that never arrive. Add an 8 MB max
payload cap; a header exceeding it is treated as corrupt, its
magic is skipped, and the demuxer resyncs on the next frame.

* feat(av-live-body): usbmux message codec

Add USBMuxProtocol, a codec for Apple's usbmuxd request/response
protocol: a 16-byte little-endian header (length, version=1,
message=8 plist, tag) followed by an XML property list.

Wire an AVLiveBodyTests test target into Package.swift (none
existed) so swift test runs the round-trip and header coverage.

* feat(av-live-body): usbmux device client

Add USBClient for usbmux device discovery and connect-to-port,
with an injectable MuxTransport so tests need no real device.

Harden USBMuxProtocol.readLE32 to return an optional with a
bounds check, avoiding an out-of-range crash on truncated data.

* feat(av-live-body): usbmuxd unix socket transport

Add UnixMuxTransport, the production MuxTransport that opens a
blocking AF_UNIX socket to /var/run/usbmuxd. Implements framed
packet reads (4-byte LE length prefix) and raw stream reads for
the tunneled byte stream after a successful Connect.

* fix(av-live-body): harden unix socket transport

Apply four code-review fixes to UnixMuxTransport:
- send() now loops on partial writes and retries on EINTR
  instead of discarding write(2)'s return value.
- Add deinit and an fd = -1 sentinel so close() is
  idempotent and the descriptor cannot leak.
- precondition guards strcpy against sun_path overflow.
- readN() distinguishes EOF from error and retries EINTR.

* feat(ios): USB TCP frame server

Add USBServer: an NWListener on a fixed local TCP port that
usbmuxd tunnels to the tethered Mac. Sends AVLiveWire frames
and exposes a connection-state callback.

* build: depend on shared AVLiveWire package

Both ARBodyTracker (iOS) and AVLiveBody (macOS) now depend on
the local shared/AVLiveWire package so the wire format is
defined once. iOS USBServer imports it; macOS use lands in
Plan 3.

* build(ios): add AVLiveWire package to xcodegen

The xcodegen project did not declare the shared AVLiveWire
package, so USBServer.swift would fail to import it in the
generated Xcode project. Add it as a local package dep.

* test(avlivewire): end-to-end chunked loopback

Feeds 20 framed skeleton payloads through StreamDemuxer in
7-byte chunks (worst-case TCP fragmentation). Fixed a split
range operator from the plan that did not parse.

* fix(ios): guard USBServer listener and payload

Report .idle (not .listening) when NWListener creation fails,
and drop payloads larger than the demuxer's 8 MB cap so the
receiver never silently discards an oversized frame.

* chore: ignore .swiftpm editor state dirs

swift test / Xcode create hidden .swiftpm dirs inside
packages; ignore them so they never get staged.

* docs: iPhone capture plan (2 of 3)

Plan for HEVC video capture (VideoToolbox) over the USB
transport and removal of the legacy OSC sender. Skeleton
USB path already exists; this adds the video half.

* feat(ios): HEVC video capture, drop OSC

Add VideoEncoder (VideoToolbox HEVC) and stream encoded
frames over USB as .video AVLiveWire frames alongside the
skeleton. Remove the legacy OSC/UDP fanout and its host/port
config UI — the iPhone link is now USB-only.

* docs(ios): refresh stale OSC references

ARBodySession header comment and Info.plist usage strings
still described the removed OSC/UDP path; update them to the
USB transport and drop the dead _osc._udp Bonjour service.

* docs: macOS USB consumer plan (3a of 3)

Plan for consuming the iPhone USB stream in AVLiveBody:
USBSkeletonConsumer, VideoDecoder, 91-joint skeleton render.
Multi-HMR dense mesh deferred to Plan 3b.

* feat(av-live-body): USB skeleton consumer

Background usbmux read loop feeding StreamDemuxer; republishes
.skeleton frames as 91-joint ArkitBodyFrames and forwards
.video payloads. Removed stale iCloud collision duplicate
source files that broke the build.

* fix(data-only): CoreML Multi-HMR usage bugs

The CoreML Multi-HMR model was fine; the "0 detections" bug
was caller-side. Add ImageNet normalization in infer() (the
DINOv2 backbone needs it; raw [0,1] input collapsed all
scores) and update stale hardcoded output var names to match
the re-converted mlpackage. Bump the latency test threshold
to the realistic ~140 ms full-model figure.

* feat(av-live-body): HEVC video decoder

VTDecompressionSession decoder for .video VideoPayloads.
Rebuilds the format description from the parameter sets
prepended to keyframe payloads by the iOS VideoEncoder.

* feat(av-live-body): render 91-joint USB skeleton

Complete the long-standing TODO: draw the 91 ARKit/USB
skeleton joints as yellow markers, fed from lastArkit. Spawn
entity trees for ARKit-only pids so the USB skeleton shows
without a MediaPipe pose.

* feat(av-live-body): wire USB consumer to renderer

ContentView owns and starts a USBSkeletonConsumer, threaded
through BodyView into Skeleton3DRenderer.attach. The renderer
subscribes its $bodies into lastArkit, so the iPhone's USB
skeleton drives the on-screen 91-joint markers.

* docs: macOS Multi-HMR mesh plan (3b of 3)

Final plan: bundle the validated FP32 mlpackage, MultiHMRCoreML
Swift wrapper, BodyFusion (ARKit depth correction), mesh
pipeline wiring. Completes the spec.

* docs: AVLiveBody macOS rewrite design

Clean-rewrite spec: fresh native macOS Xcode app for the
iPhone-USB body pipeline. Reuses the tested USB components,
single RealityKit scene (video quad + skeleton + mesh),
drops all legacy MediaPipe/viz/data-feed code.

* docs: AVLiveBody macOS rewrite plan

10-task plan: scaffold the xcodegen app, migrate the USB
pipeline, build the RealityKit scene (video quad, skeleton,
mesh), wire it, archive the legacy app.

* feat(avlivebody-mac): scaffold xcode app

Add an empty buildable native macOS app generated via xcodegen,
sibling of iphone-arbody. Depends on the shared AVLiveWire package.
Later tasks add the USB pipeline and RealityKit scene.

* feat(avlivebody-mac): migrate usb transport

Context: the new native AVLiveBody app needs the proven iPhone-Mac
usbmux transport layer. These files are self-contained, depending
only on AVLiveWire plus Apple system frameworks, so they cross the
rewrite boundary unchanged.

Approach: copy the three transport files and their unit tests
byte-for-byte from launcher/AV-Live-Body, then make the test target
buildable.

Changes:
- Add usb/USBMuxProtocol.swift, usb/USBClient.swift and
  usb/VideoDecoder.swift under Sources/AVLiveBody.
- Add USBMuxProtocolTests.swift and USBClientTests.swift under
  Tests/AVLiveBodyTests.
- Set GENERATE_INFOPLIST_FILE=YES on the AVLiveBodyTests target so
  xcodebuild can code sign the now-populated test bundle.

Impact: the usbmux pipeline is available in the rewrite and its
six unit tests run green under xcodebuild test.

* feat(avlivebody-mac): usb skeleton consumer

Add a cleaned USBSkeletonConsumer that publishes SkeletonPayload
keyed by pid and owns video decoding directly, dropping the legacy
ArkitOSCListener conversion layer.

* fix(avlivebody-mac): guard thread store with lock

Move the `thread` property write inside the stateLock-held
region in start(); t.start() stays outside since the thread
cannot run before start() is called. Removes a latent race.

* feat(avlivebody-mac): multi-hmr and body fusion

Context: Task 4 of the macOS rewrite needs the dense-mesh half of
the pipeline alongside the USB skeleton consumer landed in task 3.

Approach: Add a CoreML wrapper that mirrors the validated Python
reference (data_only_viz/multihmr_coreml.py) and a pure-logic
fusion stage that corrects the mesh pelvis depth using the
LiDAR-precise USB skeleton.

Changes:
- MultiHMRCoreML.swift: 1x3x672x672 ImageNet-normalized image
  input, 1x3x3 cam_K input, K=4 SMPL-X person outputs at
  10475 vertices, det threshold 0.3.
- BodyFusion.swift: stateless fuse(persons, skeletons) overrides
  the highest-score mesh translation.z with the skeleton pelvis Z
  when available, passes through otherwise.
- BodyFusionTests.swift: pelvis override and pass-through cases.

Impact: Unlocks the mesh renderer wiring in later tasks and gives
the macOS app metrically-correct depth in front of the camera.

* fix(avlivebody-mac): load mlmodelc, clarify fusion

Xcode compiles .mlpackage resources to .mlmodelc at build time;
look up the compiled artifact directly and drop the redundant
MLModel.compileModel step. Also rewrite BodyFusion docstring to
match actual single-person pelvis-z override behaviour.

* feat(avlivebody-mac): scene controller + view

RealityKit scene plumbing: SceneController owns ARView, orbital
camera, and holders for VideoQuad/SkeletonEntity/MeshEntity.
SceneView is the SwiftUI NSViewRepresentable bridge.

Build intentionally deferred to T8 (holder types land in T6-T8).

* fix(avlivebody): orbit gesture + setUp guard

Filter NSPanGestureRecognizer state in OrbitTarget.handlePan to
dispatch only on .changed, replacing the Task wrapper with
MainActor.assumeIsolated. Guard SceneController.setUp() with a
didSetUp flag so duplicate makeNSView calls do not re-install
gestures or re-add anchors.

* feat(avlivebody-mac): 91-joint skeleton entity

Yellow marker spheres pooled per pid; ARKit (x,y,z) ->
RealityKit (x,-y,-z). Adapted .systemYellow to NSColor for
macOS RealityKit Material.Color. Build deferred to T8.

* feat(avlivebody-mac): video quad

Flat 1.6x0.9m plane at z=-2m, textured per-frame from
CVPixelBuffer via CIImage -> CGImage -> TextureResource.
Per-frame TextureResource creation is the known perf hot
spot, isolated here for later LowLevelTexture upgrade.

* fix(avlivebody-mac): appkit import for orbit

NSPanGestureRecognizer lives in AppKit on macOS; without the
import the AVLiveBody module failed to emit. T5 leftover
surfaced once T6/T7/T8 made the target compilable.

* feat(avlivebody-mac): smpl-x mesh entity

Render SMPL-X dense meshes (10475 verts) from Multi-HMR with
pooled ModelEntity per person. Triangle indices loaded from the
bundled smplx_faces.bin (flat UInt32 triplets, copied from the
legacy launcher target). xcodegen folder-scanning bundles the
.bin under Contents/Resources/ — no project.yml change needed.

* feat(avlivebody-mac): wire scene + status bar

Replace placeholder window with ContentView wiring
USBSkeletonConsumer, SceneController, MultiHMRCoreML and
BodyFusion per the T9 dataflow plan.

* chore: archive legacy AV-Live-Body

Superseded by avlivebody-mac/ on 2026-05-18. See
docs/superpowers/specs/2026-05-18-avlivebody-macos-rewrite-design.md
for the rewrite design and rationale.

* fix(avlivebody): break onVideoFrame retain cycle

Capture consumer weakly in the onVideoFrame closure so the
USBSkeletonConsumer can be deallocated and its background thread
exits cleanly. Guard the mesh-fusion path when consumer is gone.

* fix(launcher): disable body spawn post-archive

Legacy SwiftPM target archived to launcher/_archive-AV-Live-Body/.
New native Xcode app lives at avlivebody-mac/; no swift run path.
startBodyApp now logs + no-ops with FIXME(rewrite-2026-05-18).

* docs(avlivebody-mac): contributor setup README

Document prerequisites, mlpackage copy, signing xcconfig, and
xcodegen/xcodebuild commands. Points at design spec and plan.

* refactor(avlivebody): axis helper + cleanups

- Extract arkitToRealityKit helper, dedupe 3 call sites.
- Add onDisappear consumer.stop() to terminate USB read loop.
- Replace @State with let for SceneController (stable class id).
- Add NSLog diagnostics in VideoQuad+MeshEntity silent guards.

* fix(avlivebody): ad-hoc signing for local dev

Apple Development cert + Automatic signing makes Xcode demand a
Mac Development cert that no one has. Switch to manual ad-hoc
(CODE_SIGN_IDENTITY = -) so any contributor can build. Drop
hardened runtime; re-enable for distribution builds.

* feat(arbody): keep iphone awake while streaming

iOS auto-lock tears down the USBServer TCP listener within
seconds, breaking AVLiveBody Mac-side connect. Disable the
idle timer for the lifetime of ContentView, restore on exit.

* docs: add AGENTS.md skeleton
2026-05-21 12:41:33 +02:00

34 KiB

AVLiveBody macOS Rewrite Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a fresh native macOS Xcode app, avlivebody-mac/, that consumes the iPhone-USB body pipeline and renders the camera video + 91-joint skeleton + SMPL-X mesh in one RealityKit 3D scene — no legacy code.

Architecture: A SwiftUI app whose single window hosts one RealityKit ARView (used as a plain 3D view). The proven USB pipeline components (AVLiveWire, usbmux client, VideoDecoder, USBSkeletonConsumer, MultiHMRCoreML, BodyFusion) are migrated in; new clean rendering units (SceneController, VideoQuad, SkeletonEntity, MeshEntity) build the scene. The old launcher/AV-Live-Body is archived.

Tech Stack: Swift 5, macOS 15, Xcode + xcodegen, RealityKit, CoreML, VideoToolbox, the local AVLiveWire SwiftPM package, XCTest.

Companion spec: docs/superpowers/specs/2026-05-18-avlivebody-macos-rewrite-design.md


Verification

The app is a real Xcode project. Per task:

cd avlivebody-mac && xcodegen generate && \
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
  -destination 'platform=macOS' -configuration Debug build

Expected: ** BUILD SUCCEEDED **. Unit tests (migrated) run with the xcodebuild ... test action.


File Structure

avlivebody-mac/
  project.yml                              xcodegen manifest
  Config/Shared.xcconfig                   committed build settings
  Config/Local.xcconfig.example            DEVELOPMENT_TEAM template
  Sources/AVLiveBody/
    AVLiveBodyApp.swift                    @main + AppDelegate
    SceneView.swift                        NSViewRepresentable -> ARView
    SceneController.swift                  owns scene/camera/entities
    VideoQuad.swift                        video-textured back plane
    SkeletonEntity.swift                   91 joint markers
    MeshEntity.swift                       SMPL-X 10475-vertex mesh
    StatusBar.swift                        connection-status overlay
    Info.plist
    usb/USBMuxProtocol.swift               migrated verbatim
    usb/USBClient.swift                    migrated verbatim
    usb/VideoDecoder.swift                 migrated verbatim
    usb/USBSkeletonConsumer.swift          migrated, cleaned
    usb/MultiHMRCoreML.swift               migrated verbatim
    usb/BodyFusion.swift                   migrated, cleaned
    Resources/smplx_faces.bin              SMPL-X face indices
    Resources/multihmr_full_672_s.mlpackage  bundled model (gitignored)
  Tests/AVLiveBodyTests/                   migrated unit tests

AVLiveWire stays in shared/AVLiveWire; the app depends on it.


Task 1: Scaffold the Xcode project

Files:

  • Create: avlivebody-mac/project.yml

  • Create: avlivebody-mac/Config/Shared.xcconfig

  • Create: avlivebody-mac/Config/Local.xcconfig.example

  • Create: avlivebody-mac/Sources/AVLiveBody/AVLiveBodyApp.swift

  • Create: avlivebody-mac/Sources/AVLiveBody/Info.plist

  • Step 1: Create avlivebody-mac/project.yml

name: AVLiveBody
options:
  bundleIdPrefix: cc.saillant
  deploymentTarget:
    macOS: "15.0"
  createIntermediateGroups: true

configFiles:
  Debug: Config/Shared.xcconfig
  Release: Config/Shared.xcconfig

packages:
  AVLiveWire:
    path: ../shared/AVLiveWire

targets:
  AVLiveBody:
    type: application
    platform: macOS
    deploymentTarget: "15.0"
    sources:
      - path: Sources/AVLiveBody
        excludes:
          - Info.plist
    dependencies:
      - package: AVLiveWire
        product: AVLiveWire
    configFiles:
      Debug: Config/Shared.xcconfig
      Release: Config/Shared.xcconfig
    settings:
      base:
        PRODUCT_NAME: AVLiveBody
        PRODUCT_BUNDLE_IDENTIFIER: cc.saillant.AVLiveBody
        INFOPLIST_FILE: Sources/AVLiveBody/Info.plist
        GENERATE_INFOPLIST_FILE: NO
        CODE_SIGN_STYLE: Automatic
        SWIFT_VERSION: "5.10"
        ENABLE_HARDENED_RUNTIME: YES
  AVLiveBodyTests:
    type: bundle.unit-test
    platform: macOS
    sources:
      - path: Tests/AVLiveBodyTests
    dependencies:
      - target: AVLiveBody
      - package: AVLiveWire
        product: AVLiveWire
  • Step 2: Create the config files

avlivebody-mac/Config/Shared.xcconfig:

#include? "Local.xcconfig"

MACOSX_DEPLOYMENT_TARGET = 15.0
SWIFT_VERSION = 5.10
CODE_SIGN_STYLE = Automatic

avlivebody-mac/Config/Local.xcconfig.example:

// Copy to Config/Local.xcconfig and set your Apple Developer Team ID.
// Config/Local.xcconfig is gitignored.
DEVELOPMENT_TEAM = YOUR_TEAM_ID
  • Step 3: Create the minimal app + Info.plist

avlivebody-mac/Sources/AVLiveBody/AVLiveBodyApp.swift:

import Cocoa
import SwiftUI

/// Forces a regular, keyboard-focusable foreground app.
final class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        NSApp.setActivationPolicy(.regular)
        NSApp.activate()
    }
}

@main
struct AVLiveBodyApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self)
    private var appDelegate

    var body: some Scene {
        WindowGroup {
            Text("AVLiveBody")
                .frame(minWidth: 900, minHeight: 600)
        }
    }
}

avlivebody-mac/Sources/AVLiveBody/Info.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleName</key><string>AVLiveBody</string>
    <key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
    <key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
    <key>CFBundlePackageType</key><string>APPL</string>
    <key>CFBundleShortVersionString</key><string>1.0</string>
    <key>CFBundleVersion</key><string>1</string>
    <key>LSMinimumSystemVersion</key><string>15.0</string>
    <key>NSCameraUsageDescription</key>
    <string>Receives the tethered iPhone camera over USB.</string>
    <key>NSLocalNetworkUsageDescription</key>
    <string>Connects to the tethered iPhone over USB (usbmuxd).</string>
</dict>
</plist>
  • Step 4: Generate and build
cd avlivebody-mac && xcodegen generate && \
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
  -destination 'platform=macOS' -configuration Debug build

Expected: ** BUILD SUCCEEDED **, an empty window app.

  • Step 5: Commit (the generated .xcodeproj is gitignored — add an avlivebody-mac/.gitignore with *.xcodeproj/ and Config/Local.xcconfig)
git add avlivebody-mac/project.yml avlivebody-mac/Config avlivebody-mac/Sources avlivebody-mac/.gitignore
git commit -m "feat(avlivebody-mac): scaffold xcode app"

Task 2: Migrate the USB transport files

These files are copied verbatim from launcher/AV-Live-Body/Sources/AVLiveBody/ — they are self-contained and depend only on AVLiveWire + system frameworks.

Files:

  • Create: avlivebody-mac/Sources/AVLiveBody/usb/USBMuxProtocol.swift (copy of the existing file)

  • Create: avlivebody-mac/Sources/AVLiveBody/usb/USBClient.swift (copy)

  • Create: avlivebody-mac/Sources/AVLiveBody/usb/VideoDecoder.swift (copy)

  • Step 1: Copy the three files

mkdir -p avlivebody-mac/Sources/AVLiveBody/usb
cp launcher/AV-Live-Body/Sources/AVLiveBody/USBMuxProtocol.swift \
   launcher/AV-Live-Body/Sources/AVLiveBody/USBClient.swift \
   launcher/AV-Live-Body/Sources/AVLiveBody/VideoDecoder.swift \
   avlivebody-mac/Sources/AVLiveBody/usb/
  • Step 2: Migrate the unit tests for them
mkdir -p avlivebody-mac/Tests/AVLiveBodyTests
cp launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBMuxProtocolTests.swift \
   launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBClientTests.swift \
   avlivebody-mac/Tests/AVLiveBodyTests/
  • Step 3: Build + test
cd avlivebody-mac && xcodegen generate && \
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
  -destination 'platform=macOS' test

Expected: ** TEST SUCCEEDED **, the USBMux/USBClient tests pass.

  • Step 4: Commit
git add avlivebody-mac/Sources/AVLiveBody/usb avlivebody-mac/Tests
git commit -m "feat(avlivebody-mac): migrate usb transport"

Task 3: Migrate USBSkeletonConsumer (cleaned)

The old USBSkeletonConsumer converts SkeletonPayload into the legacy ArkitOSCListener.ArkitBodyFrame. The new app drops ArkitOSCListener entirely, so the consumer publishes [Int: SkeletonPayload] directly.

Files:

  • Create: avlivebody-mac/Sources/AVLiveBody/usb/USBSkeletonConsumer.swift

  • Step 1: Create the cleaned file

import AVLiveWire
import Combine
import CoreVideo
import Foundation

/// Connects to the tethered iPhone over USB (usbmuxd), demuxes the
/// AVLiveWire stream, republishes skeleton payloads (keyed by pid)
/// and forwards decoded camera frames. Blocking transport runs on a
/// dedicated background thread; only `@Published` writes hop to main.
final class USBSkeletonConsumer: ObservableObject {
    /// 91-joint skeleton payloads keyed by pid.
    @Published var skeletons: [Int: SkeletonPayload] = [:]
    @Published var connected = false

    /// Called on the main queue for every decoded camera frame.
    var onVideoFrame: ((CVPixelBuffer) -> Void)?

    /// TCP port the iPhone `USBServer` listens on.
    static let devicePort: UInt16 = 7000

    private let videoDecoder = VideoDecoder()
    private let stateLock = NSLock()
    private var running = false
    private var thread: Thread?

    init() {
        videoDecoder.onFrame = { [weak self] pixelBuffer in
            DispatchQueue.main.async {
                self?.onVideoFrame?(pixelBuffer)
            }
        }
    }

    private var isRunning: Bool {
        stateLock.lock(); defer { stateLock.unlock() }
        return running
    }

    func start() {
        stateLock.lock()
        if running { stateLock.unlock(); return }
        running = true
        stateLock.unlock()
        let t = Thread { [weak self] in self?.loop() }
        t.name = "cc.avlive.usbconsumer"
        t.start()
        thread = t
    }

    func stop() {
        stateLock.lock(); running = false; stateLock.unlock()
    }

    private func loop() {
        while isRunning {
            guard let transport = UnixMuxTransport() else {
                NSLog("USBSkeletonConsumer: no usbmuxd; retry")
                Thread.sleep(forTimeInterval: 1.0); continue
            }
            let client = USBClient(transport: transport)
            let devices = client.listDevices()
            guard let dev = devices.first,
                  client.connect(deviceID: dev,
                                 port: Self.devicePort) else {
                NSLog("USBSkeletonConsumer: no device; retry")
                transport.close()
                Thread.sleep(forTimeInterval: 1.0); continue
            }
            NSLog("USBSkeletonConsumer: connected to device %d", dev)
            publishConnected(true)
            var demux = StreamDemuxer()
            while isRunning {
                guard let chunk = transport.readStream(),
                      !chunk.isEmpty else { break }
                for frame in demux.feed(chunk) { route(frame) }
            }
            transport.close()
            publishConnected(false)
            NSLog("USBSkeletonConsumer: disconnected")
            if isRunning { Thread.sleep(forTimeInterval: 1.0) }
        }
    }

    private func route(_ frame: StreamDemuxer.Frame) {
        switch frame.header.tag {
        case .skeleton:
            guard let payload =
                SkeletonPayload(decoding: frame.payload) else { return }
            let pid = Int(frame.header.pid)
            DispatchQueue.main.async { [weak self] in
                self?.skeletons[pid] = payload
            }
        case .video:
            guard let payload =
                VideoPayload(decoding: frame.payload) else { return }
            videoDecoder.decode(payload)
        case .meta:
            break
        }
    }

    private func publishConnected(_ value: Bool) {
        DispatchQueue.main.async { [weak self] in
            self?.connected = value
        }
    }
}
  • Step 2: Build
cd avlivebody-mac && xcodegen generate && \
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
  -destination 'platform=macOS' build

Expected: ** BUILD SUCCEEDED **.

  • Step 3: Commit
git add avlivebody-mac/Sources/AVLiveBody/usb/USBSkeletonConsumer.swift
git commit -m "feat(avlivebody-mac): usb skeleton consumer"

Task 4: Migrate MultiHMRCoreML + BodyFusion (cleaned)

Copy MultiHMRCoreML.swift verbatim. Adapt BodyFusion to take [Int: SkeletonPayload] instead of the legacy ArkitBodyFrame.

Files:

  • Create: avlivebody-mac/Sources/AVLiveBody/usb/MultiHMRCoreML.swift (copy)

  • Create: avlivebody-mac/Sources/AVLiveBody/usb/BodyFusion.swift

  • Test: avlivebody-mac/Tests/AVLiveBodyTests/BodyFusionTests.swift

  • Step 1: Copy MultiHMRCoreML.swift

cp launcher/AV-Live-Body/Sources/AVLiveBody/MultiHMRCoreML.swift \
   avlivebody-mac/Sources/AVLiveBody/usb/
  • Step 2: Write the BodyFusion test

avlivebody-mac/Tests/AVLiveBodyTests/BodyFusionTests.swift:

import XCTest
import AVLiveWire
@testable import AVLiveBody

final class BodyFusionTests: XCTestCase {
    private func skeleton(pelvisZ: Float) -> SkeletonPayload {
        var p = SkeletonPayload()
        p.joints[0] = SIMD3(0, 0, pelvisZ)
        p.valid[0] = true
        return p
    }

    func testPelvisDepthOverride() {
        let mesh = MultiHMRPerson(
            vertices: [SIMD3<Float>](repeating: .zero, count: 1),
            translation: SIMD3(0, 0, -1.0), score: 0.9)
        let fused = BodyFusion.fuse(
            persons: [mesh], skeletons: [0: skeleton(pelvisZ: -2.5)])
        XCTAssertEqual(fused[0].translation.z, -2.5, accuracy: 1e-4)
    }

    func testPassthroughWhenNoSkeleton() {
        let mesh = MultiHMRPerson(
            vertices: [SIMD3<Float>](repeating: .zero, count: 1),
            translation: SIMD3(0, 0, -1.0), score: 0.9)
        let fused = BodyFusion.fuse(persons: [mesh], skeletons: [:])
        XCTAssertEqual(fused[0].translation.z, -1.0, accuracy: 1e-4)
    }
}
  • Step 3: Run the test to verify it fails

Run: cd avlivebody-mac && xcodegen generate && xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody -destination 'platform=macOS' test Expected: FAIL — BodyFusion undefined.

  • Step 4: Write BodyFusion.swift

avlivebody-mac/Sources/AVLiveBody/usb/BodyFusion.swift:

import AVLiveWire
import Foundation
import simd

/// Associates Multi-HMR meshes with USB skeletons and corrects the
/// mesh pelvis depth. Pure, stateless — unit-testable.
enum BodyFusion {
    /// SMPL-X / ARKit body root (hips) joint index.
    static let pelvisJoint = 0

    static func fuse(persons: [MultiHMRPerson],
                     skeletons: [Int: SkeletonPayload])
        -> [MultiHMRPerson] {
        let pelvisZs: [Float] = skeletons.values.compactMap { s in
            guard pelvisJoint < s.valid.count,
                  s.valid[pelvisJoint] else { return nil }
            return s.joints[pelvisJoint].z
        }
        guard !pelvisZs.isEmpty,
              let primaryIdx = persons.indices.max(by: {
                  persons[$0].score < persons[$1].score
              }) else { return persons }
        var out = persons
        out[primaryIdx].translation.z = pelvisZs[0]
        return out
    }
}
  • Step 5: Run the test to verify it passes

Run the test command. Expected: BodyFusionTests pass.

  • Step 6: Commit
git add avlivebody-mac/Sources/AVLiveBody/usb/MultiHMRCoreML.swift avlivebody-mac/Sources/AVLiveBody/usb/BodyFusion.swift avlivebody-mac/Tests/AVLiveBodyTests/BodyFusionTests.swift
git commit -m "feat(avlivebody-mac): multi-hmr and body fusion"

Task 5: SceneController + SceneView

SceneController owns the RealityKit scene, an orbital camera, and the entity roots. SceneView is the NSViewRepresentable bridge.

Files:

  • Create: avlivebody-mac/Sources/AVLiveBody/SceneController.swift

  • Create: avlivebody-mac/Sources/AVLiveBody/SceneView.swift

  • Step 1: Write SceneController.swift

import Foundation
import RealityKit
import simd

/// Owns the single RealityKit scene: the video quad, the body root,
/// and an orbital camera. The app calls `updateVideo/updateSkeleton/
/// updateMesh` from the main queue.
@MainActor
final class SceneController {
    let arView = ARView(frame: .zero)

    private let cameraAnchor = AnchorEntity(world: .zero)
    private let camera = PerspectiveCamera()
    private let worldAnchor = AnchorEntity(world: .zero)

    private(set) var videoQuad: VideoQuad?
    private(set) var skeleton: SkeletonEntity?
    private(set) var mesh: MeshEntity?

    /// Orbital camera state.
    private var orbitYaw: Float = 0
    private var orbitPitch: Float = 0
    private var orbitRadius: Float = 3.0

    func setUp() {
        arView.environment.background = .color(.black)
        arView.scene.addAnchor(worldAnchor)

        camera.camera.fieldOfViewInDegrees = 55
        cameraAnchor.addChild(camera)
        arView.scene.addAnchor(cameraAnchor)
        applyCamera()

        let q = VideoQuad()
        worldAnchor.addChild(q.entity)
        videoQuad = q

        let s = SkeletonEntity()
        worldAnchor.addChild(s.root)
        skeleton = s

        let m = MeshEntity()
        worldAnchor.addChild(m.root)
        mesh = m

        installOrbitGestures()
    }

    func updateVideo(_ pixelBuffer: CVPixelBuffer) {
        videoQuad?.update(pixelBuffer)
    }

    func updateSkeleton(_ skeletons: [Int: SkeletonPayload]) {
        skeleton?.update(skeletons)
    }

    func updateMesh(_ persons: [MultiHMRPerson]) {
        mesh?.update(persons)
    }

    // MARK: - Orbital camera

    private func applyCamera() {
        let cy = cos(orbitYaw), sy = sin(orbitYaw)
        let cp = cos(orbitPitch), sp = sin(orbitPitch)
        let pos = SIMD3<Float>(orbitRadius * cp * sy,
                               orbitRadius * sp,
                               orbitRadius * cp * cy)
        cameraAnchor.transform.translation = pos
        camera.look(at: .zero, from: pos, relativeTo: nil)
    }

    private func installOrbitGestures() {
        let pan = NSPanGestureRecognizer(
            target: OrbitTarget.shared, action: #selector(
                OrbitTarget.handlePan(_:)))
        OrbitTarget.shared.controller = self
        arView.addGestureRecognizer(pan)
    }

    fileprivate func orbit(dx: Float, dy: Float) {
        orbitYaw += dx * 0.01
        orbitPitch = max(-1.4, min(1.4, orbitPitch + dy * 0.01))
        applyCamera()
    }
}

/// Bridges the AppKit pan gesture to `SceneController.orbit`.
final class OrbitTarget: NSObject {
    static let shared = OrbitTarget()
    weak var controller: SceneController?
    private var last: CGPoint = .zero

    @objc func handlePan(_ g: NSPanGestureRecognizer) {
        let p = g.translation(in: g.view)
        if g.state == .began { last = p }
        let dx = Float(p.x - last.x)
        let dy = Float(p.y - last.y)
        last = p
        Task { @MainActor in
            self.controller?.orbit(dx: dx, dy: -dy)
        }
    }
}
  • Step 2: Write SceneView.swift
import RealityKit
import SwiftUI

/// SwiftUI bridge that hands the SceneController's ARView to the
/// window and runs `setUp()` once.
struct SceneView: NSViewRepresentable {
    let controller: SceneController

    func makeNSView(context: Context) -> ARView {
        controller.setUp()
        return controller.arView
    }

    func updateNSView(_ view: ARView, context: Context) {}
}
  • Step 3: Build

VideoQuad, SkeletonEntity, MeshEntity do not exist yet — this task will not build alone. Proceed to Tasks 6-8, then build at Task 8. (Stub note: the build is verified at the end of Task 8.)

  • Step 4: Commit
git add avlivebody-mac/Sources/AVLiveBody/SceneController.swift avlivebody-mac/Sources/AVLiveBody/SceneView.swift
git commit -m "feat(avlivebody-mac): scene controller + view"

Task 6: SkeletonEntity

91 joint marker spheres under a root entity.

Files:

  • Create: avlivebody-mac/Sources/AVLiveBody/SkeletonEntity.swift

  • Step 1: Write the file

import AVLiveWire
import Foundation
import RealityKit
import simd

/// Renders 91-joint skeletons as yellow marker spheres. One marker
/// pool per pid. ARKit world coords -> RealityKit space (x, -y, -z).
@MainActor
final class SkeletonEntity {
    let root = Entity()

    private static let jointCount = 91
    private static let markerRadius: Float = 0.012

    private var pools: [Int: [ModelEntity]] = [:]
    private let mesh = MeshResource.generateSphere(radius: markerRadius)
    private let material = SimpleMaterial(
        color: .systemYellow, roughness: 0.6, isMetallic: false)

    func update(_ skeletons: [Int: SkeletonPayload]) {
        // Drop pools for pids no longer present.
        for pid in pools.keys where skeletons[pid] == nil {
            pools[pid]?.forEach { $0.removeFromParent() }
            pools.removeValue(forKey: pid)
        }
        for (pid, payload) in skeletons {
            let pool = pools[pid] ?? makePool()
            pools[pid] = pool
            let n = min(Self.jointCount, payload.joints.count,
                        payload.valid.count)
            for i in 0..<n {
                let marker = pool[i]
                if payload.valid[i] {
                    let j = payload.joints[i]
                    marker.transform.translation =
                        SIMD3<Float>(j.x, -j.y, -j.z)
                    marker.isEnabled = true
                } else {
                    marker.isEnabled = false
                }
            }
        }
    }

    private func makePool() -> [ModelEntity] {
        var pool: [ModelEntity] = []
        pool.reserveCapacity(Self.jointCount)
        for _ in 0..<Self.jointCount {
            let e = ModelEntity(mesh: mesh, materials: [material])
            e.isEnabled = false
            root.addChild(e)
            pool.append(e)
        }
        return pool
    }
}
  • Step 2: Commit (build verified at Task 8)
git add avlivebody-mac/Sources/AVLiveBody/SkeletonEntity.swift
git commit -m "feat(avlivebody-mac): 91-joint skeleton entity"

Task 7: VideoQuad

A flat plane whose unlit material texture is replaced from each decoded CVPixelBuffer. This is the spec's flagged hard point — RealityKit has no direct pixel-buffer-stream texture path; we rebuild a TextureResource per frame from a CGImage.

Files:

  • Create: avlivebody-mac/Sources/AVLiveBody/VideoQuad.swift

  • Step 1: Write the file

import CoreImage
import CoreVideo
import Foundation
import RealityKit

/// A flat plane at the back of the scene, textured with the iPhone
/// camera video. `update(_:)` is called on the main queue per frame.
@MainActor
final class VideoQuad {
    let entity = ModelEntity()

    private let ciContext = CIContext()
    /// Plane is 1.6 m wide, 16:9; positioned 2 m behind the body.
    private static let width: Float = 1.6
    private static let height: Float = 0.9
    private static let zBack: Float = -2.0

    init() {
        let plane = MeshResource.generatePlane(
            width: Self.width, height: Self.height)
        var material = UnlitMaterial()
        material.color = .init(tint: .white)
        entity.model = ModelComponent(mesh: plane,
                                      materials: [material])
        entity.transform.translation =
            SIMD3<Float>(0, 0, Self.zBack)
    }

    /// Replace the plane's texture from a decoded camera frame.
    func update(_ pixelBuffer: CVPixelBuffer) {
        let ci = CIImage(cvPixelBuffer: pixelBuffer)
        guard let cg = ciContext.createCGImage(
            ci, from: ci.extent) else { return }
        guard let texture = try? TextureResource(
            image: cg,
            options: .init(semantic: .color)) else { return }
        var material = UnlitMaterial()
        material.color = .init(tint: .white,
                               texture: .init(texture))
        entity.model?.materials = [material]
    }
}

Note: per-frame CGImage + TextureResource creation is the known performance hot spot. It is isolated here so a later iteration can switch to LowLevelTexture / a Metal-backed update without touching callers.

  • Step 2: Commit (build verified at Task 8)
git add avlivebody-mac/Sources/AVLiveBody/VideoQuad.swift
git commit -m "feat(avlivebody-mac): video quad"

Task 8: MeshEntity

Renders the SMPL-X dense mesh (10475 vertices) from Multi-HMR. The triangle indices are loaded from a bundled smplx_faces.bin (the same face-index binary the old app used: a flat array of UInt32 triplets).

Files:

  • Create (copy): avlivebody-mac/Sources/AVLiveBody/Resources/smplx_faces.bin

  • Create: avlivebody-mac/Sources/AVLiveBody/MeshEntity.swift

  • Step 1: Copy the face-index resource

mkdir -p avlivebody-mac/Sources/AVLiveBody/Resources
cp launcher/AV-Live-Body/Sources/AVLiveBody/Resources/smplx_faces.bin \
   avlivebody-mac/Sources/AVLiveBody/Resources/

Declare it in project.yml — add under the AVLiveBody target a resources style copy by adding to sources a buildPhase, or simply keep it in Sources/AVLiveBody/Resources (xcodegen copies unknown files as resources of the folder reference). Verify after Step 3 that Bundle.main.url(forResource: "smplx_faces", withExtension: "bin") resolves; if not, add an explicit resource entry to project.yml.

  • Step 2: Write MeshEntity.swift
import Foundation
import RealityKit
import simd

/// Renders SMPL-X dense body meshes (10475 vertices) from Multi-HMR.
/// Triangle indices come from the bundled `smplx_faces.bin`
/// (flat UInt32 triplets).
@MainActor
final class MeshEntity {
    let root = Entity()

    private static let vertexCount = 10475
    private let faces: [UInt32]
    private var pools: [Int: ModelEntity] = [:]
    private let material = SimpleMaterial(
        color: .init(white: 0.8, alpha: 1.0),
        roughness: 0.5, isMetallic: false)

    init() {
        faces = MeshEntity.loadFaces()
    }

    /// Build/refresh one mesh per detected person.
    func update(_ persons: [MultiHMRPerson]) {
        for (idx, person) in persons.enumerated() {
            let entity = pools[idx] ?? {
                let e = ModelEntity()
                root.addChild(e)
                pools[idx] = e
                return e
            }()
            guard let mesh = buildMesh(person.vertices) else { continue }
            entity.model = ModelComponent(mesh: mesh,
                                          materials: [material])
            // RealityKit space conversion + fused translation.
            let t = person.translation
            entity.transform.translation =
                SIMD3<Float>(t.x, -t.y, -t.z)
            entity.isEnabled = true
        }
        for idx in pools.keys where idx >= persons.count {
            pools[idx]?.isEnabled = false
        }
    }

    private func buildMesh(_ verts: [SIMD3<Float>])
        -> MeshResource? {
        guard verts.count == Self.vertexCount,
              !faces.isEmpty else { return nil }
        var descriptor = MeshDescriptor(name: "smplx")
        // Model->RealityKit space (x, -y, -z).
        descriptor.positions = MeshBuffer(verts.map {
            SIMD3<Float>($0.x, -$0.y, -$0.z)
        })
        descriptor.primitives = .triangles(faces)
        return try? MeshResource.generate(from: [descriptor])
    }

    private static func loadFaces() -> [UInt32] {
        guard let url = Bundle.main.url(
            forResource: "smplx_faces", withExtension: "bin"),
              let data = try? Data(contentsOf: url) else {
            NSLog("MeshEntity: smplx_faces.bin missing")
            return []
        }
        return data.withUnsafeBytes { raw in
            Array(raw.bindMemory(to: UInt32.self))
        }
    }
}
  • Step 3: Build the whole app (Tasks 5-8 together)
cd avlivebody-mac && xcodegen generate && \
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
  -destination 'platform=macOS' build

Expected: ** BUILD SUCCEEDED **SceneController, SceneView, SkeletonEntity, VideoQuad, MeshEntity all compile together. Fix any RealityKit signature mismatch minimally (the RealityKit mesh / texture APIs are the likely friction points; preserve behavior).

  • Step 4: Commit
git add avlivebody-mac/Sources/AVLiveBody/MeshEntity.swift avlivebody-mac/Sources/AVLiveBody/Resources avlivebody-mac/project.yml
git commit -m "feat(avlivebody-mac): smpl-x mesh entity"

Task 9: Wire the app together

Replace the placeholder window with the scene, own the consumer + the CoreML pipeline, show a status bar.

Files:

  • Modify: avlivebody-mac/Sources/AVLiveBody/AVLiveBodyApp.swift

  • Create: avlivebody-mac/Sources/AVLiveBody/StatusBar.swift

  • Create (copy): avlivebody-mac/Sources/AVLiveBody/Resources/multihmr_full_672_s.mlpackage

  • Step 1: Bundle the CoreML model

cp -R ~/.cache/av-live-multihmr/multihmr_full_672_s.mlpackage \
   avlivebody-mac/Sources/AVLiveBody/Resources/

It is gitignored (*.mlpackage) — a build input, never committed. If absent, STOP — see voie 2 / data_only_viz/scripts/coreml_full_probe.py.

  • Step 2: Write StatusBar.swift
import SwiftUI

/// A thin overlay showing the USB connection state.
struct StatusBar: View {
    @ObservedObject var consumer: USBSkeletonConsumer

    var body: some View {
        HStack(spacing: 6) {
            Circle()
                .fill(consumer.connected ? Color.green : Color.orange)
                .frame(width: 9, height: 9)
            Text(consumer.connected
                 ? "iPhone connected (USB)"
                 : "waiting for iPhone…")
                .font(.caption)
                .foregroundStyle(.white)
            Spacer()
        }
        .padding(8)
        .background(.black.opacity(0.5))
    }
}
  • Step 3: Rewrite AVLiveBodyApp.swift
import Cocoa
import CoreVideo
import SwiftUI

final class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        NSApp.setActivationPolicy(.regular)
        NSApp.activate()
    }
}

@main
struct AVLiveBodyApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self)
    private var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
                .frame(minWidth: 900, minHeight: 600)
        }
    }
}

struct ContentView: View {
    @StateObject private var consumer = USBSkeletonConsumer()
    @State private var controller = SceneController()
    private let multiHMR = MultiHMRCoreML()
    /// Placeholder intrinsics until a `.meta` frame supplies real ones.
    private let cameraK: [Float] = [
        672, 0, 336, 0, 672, 336, 0, 0, 1,
    ]

    var body: some View {
        ZStack(alignment: .top) {
            SceneView(controller: controller)
            StatusBar(consumer: consumer)
        }
        .onAppear { wire() }
        .onReceive(consumer.$skeletons) { skeletons in
            controller.updateSkeleton(skeletons)
        }
    }

    private func wire() {
        consumer.onVideoFrame = { pixelBuffer in
            controller.updateVideo(pixelBuffer)
            if let hmr = multiHMR {
                let raw = hmr.infer(pixelBuffer, cameraK: cameraK)
                let fused = BodyFusion.fuse(
                    persons: raw, skeletons: consumer.skeletons)
                controller.updateMesh(fused)
            }
        }
        consumer.start()
    }
}
  • Step 4: Build
cd avlivebody-mac && xcodegen generate && \
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
  -destination 'platform=macOS' build

Expected: ** BUILD SUCCEEDED **.

  • Step 5: Commit
git add avlivebody-mac/Sources/AVLiveBody/AVLiveBodyApp.swift avlivebody-mac/Sources/AVLiveBody/StatusBar.swift avlivebody-mac/project.yml
git commit -m "feat(avlivebody-mac): wire scene, consumer, status"

Task 10: Archive the old app + final verification

Files:

  • Modify: launcher/CLAUDE.md (note the archival)

  • Step 1: Archive the old AVLiveBody

git mv launcher/AV-Live-Body launcher/_archive-AV-Live-Body

Add a one-line note at the top of launcher/_archive-AV-Live-Body/ (create ARCHIVED.md): "Superseded by avlivebody-mac/ on 2026-05-18 — see docs/superpowers/specs/2026-05-18-avlivebody-macos-rewrite-design.md."

  • Step 2: Full clean build + tests
cd avlivebody-mac && xcodegen generate && \
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
  -destination 'platform=macOS' clean build test

Expected: ** BUILD SUCCEEDED ** and ** TEST SUCCEEDED **.

  • Step 3: Commit
git add launcher/_archive-AV-Live-Body/ARCHIVED.md launcher/CLAUDE.md
git commit -m "chore: archive legacy AV-Live-Body"

Self-Review

  • Spec coverage: Every spec component is built — scaffold (T1), USB transport (T2), USBSkeletonConsumer (T3), MultiHMRCoreML + BodyFusion (T4), SceneController/SceneView (T5), SkeletonEntity (T6), VideoQuad (T7), MeshEntity (T8), app wiring + StatusBar (T9), archival (T10). The data flow (consumer → controller → entities) is wired in T9.
  • Placeholders: none — every step has concrete code or an exact command. T5 Step 3 deliberately defers the build to T8 because SceneController references entities created in T6-T8; this ordering note is explicit, not a placeholder.
  • Type consistency: USBSkeletonConsumer publishes [Int: SkeletonPayload]; SceneController.updateSkeleton and SkeletonEntity.update consume exactly that. BodyFusion.fuse takes [Int: SkeletonPayload] (cleaned from the legacy ArkitBodyFrame) and [MultiHMRPerson]; MultiHMRCoreML.infer produces [MultiHMRPerson]; MeshEntity.update consumes it. VideoDecoder.onFrame/USBSkeletonConsumer.onVideoFrame/ SceneController.updateVideo all carry CVPixelBuffer.
  • Known risks (from the spec): VideoQuad's per-frame CGImage+TextureResource rebuild is a perf hot spot — isolated in one unit. SceneController's orbital camera uses an AppKit pan gesture bridged via OrbitTarget. The RealityKit MeshDescriptor/ TextureResource APIs are the most likely to need a minor signature fix on the live SDK — T8 Step 3 calls this out.