* 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 (commit4e7101c) - 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 originalf540158design: 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 perf540158). * 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
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
.xcodeprojis gitignored — add anavlivebody-mac/.gitignorewith*.xcodeproj/andConfig/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
SceneControllerreferences entities created in T6-T8; this ordering note is explicit, not a placeholder. - Type consistency:
USBSkeletonConsumerpublishes[Int: SkeletonPayload];SceneController.updateSkeletonandSkeletonEntity.updateconsume exactly that.BodyFusion.fusetakes[Int: SkeletonPayload](cleaned from the legacyArkitBodyFrame) and[MultiHMRPerson];MultiHMRCoreML.inferproduces[MultiHMRPerson];MeshEntity.updateconsumes it.VideoDecoder.onFrame/USBSkeletonConsumer.onVideoFrame/SceneController.updateVideoall carryCVPixelBuffer. - Known risks (from the spec):
VideoQuad's per-frameCGImage+TextureResourcerebuild is a perf hot spot — isolated in one unit.SceneController's orbital camera uses an AppKit pan gesture bridged viaOrbitTarget. The RealityKitMeshDescriptor/TextureResourceAPIs are the most likely to need a minor signature fix on the live SDK — T8 Step 3 calls this out.