From dfd1bbca0fad72bee86eef7f31eb4534cdbaddcd Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:42:00 -0600 Subject: [PATCH] Hand recorder: hooks for the standalone Hand Recorder frametop-hand-recorder (a separate repo, with this one as a submodule) ships hands/ with its own dashboard window and binaries built for the SteamOS host. A standalone.json at the top of its tree marks it (takes.standalone()): - session.py starts ft-hands directly instead of through distrobox, for recording and tracking - session.json's and the manifest's tool name its build ("ft-handrec ( )") - the repair hints say to run its install command again (fix_hint) ft_handrec.py takes --qml and --style for that window, and has toolVersion and standalone for it. The Makefile passes LDFLAGS to the ft-hands links (its release links them statically), and test_qml_backend.py checks another window with FT_HANDREC_QML. Co-Authored-By: Claude Opus 5.5 --- hands/Makefile | 8 +++-- hands/rec/DESIGN.md | 11 ++++++ hands/rec/ft_handrec.py | 21 +++++++++-- hands/rec/session.py | 47 ++++++++++++------------ hands/rec/takes.py | 20 ++++++++++- hands/rec/tests/test_qml_backend.py | 6 ++-- hands/rec/tests/test_session.py | 56 +++++++++++++++++++++++++++++ 7 files changed, 138 insertions(+), 31 deletions(-) diff --git a/hands/Makefile b/hands/Makefile index a606775..3c90b64 100644 --- a/hands/Makefile +++ b/hands/Makefile @@ -10,6 +10,8 @@ CFLAGS ?= -O2 -g -Wall -Wextra -Wno-unused-parameter CXXFLAGS ?= -O2 -g -Wall -Wextra -Wno-unused-parameter -Wno-psabi CXXFLAGS += -std=c++17 -fopenmp -I$(NCNN)/include/ncnn LDLIBS = $(NCNN)/lib/libncnn.a -ljsoncpp -fopenmp -lpthread +# The standalone Hand Recorder's release build passes LDFLAGS=-static (it runs on the host). +LDFLAGS ?= CAMD = camd/camd.c camd/tp.c camd/xrcams.c TRACK = track/calib.cpp track/nets.cpp track/tracker.cpp track/io.cpp track/record.cpp track/pinch.cpp track/sides.cpp @@ -24,15 +26,15 @@ build/ft-camd: $(CAMD) camd/tp.h camd/xrcams.h camd/fhring.h build/ft-hands: track/main.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a @mkdir -p build - $(CXX) $(CXXFLAGS) -o $@ track/main.cpp $(TRACK) $(LDLIBS) + $(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ track/main.cpp $(TRACK) $(LDLIBS) build/ft-handreplay: track/replay.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a @mkdir -p build - $(CXX) $(CXXFLAGS) -o $@ track/replay.cpp $(TRACK) $(LDLIBS) + $(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ track/replay.cpp $(TRACK) $(LDLIBS) build/sides-test: tests/sides_test.cpp $(TRACK) $(HDR) $(NCNN)/lib/libncnn.a @mkdir -p build - $(CXX) $(CXXFLAGS) -o $@ tests/sides_test.cpp $(TRACK) $(LDLIBS) + $(CXX) $(CXXFLAGS) $(LDFLAGS) -o $@ tests/sides_test.cpp $(TRACK) $(LDLIBS) check: build/sides-test build/sides-test diff --git a/hands/rec/DESIGN.md b/hands/rec/DESIGN.md index 1e99bd4..acff0d9 100644 --- a/hands/rec/DESIGN.md +++ b/hands/rec/DESIGN.md @@ -23,6 +23,17 @@ The plan this belongs to is `~/Desktop/Projects/frame-hands/notes/hands-plan.md` Every part runs in the dev container, as ft-hands and Input Settings do. The host Python isn't used: it lacks PySide6 and zstd. `setup/dev-container.sh` gains `zstd`. +### The standalone Hand Recorder + +[frametop-hand-recorder](https://github.com/DeeJanuz/frametop-hand-recorder) is the recorder for people without Frametop. It pins this repo as a submodule and ships the parts above with its own window, a Qt Quick Controls `main.qml` sized for SteamVR's dashboard. Its release builds the binaries for the SteamOS host: ft-camd and ft-hands statically (`make LDFLAGS=-static`), and ft-handpanel against SteamVR's `libopenvr_api`. Everything runs on the host, the Python from a venv (`ft_handrec.py --qml ITS_QML --style Basic`). + +Its tarball keeps this tree's layout (`hands/build/`, `hands/rec/`, `hands/models/`), so the paths here don't change. A `standalone.json` at the top marks it (`takes.standalone()`): +- `session.py` starts ft-hands directly, not through distrobox. +- session.json's `tool` and the manifest's are `ft-handrec (frametop-hand-recorder )`. +- The repair hints say to run its install command again. + +The recordings go to the same `~/.local/share/frametop/hands/contrib`, so either recorder shows both's sessions. + ## Processes during a session - **ft-camd** publishes the camera ring. If it isn't running, the session starts it as the transient user unit `frametop-handrec-camd.service`, the way `hands/ft-cutouts` starts `frametop-cutouts-camd.service` (needs `hands/build/ft-camd` with capabilities: `hands/run.sh caps`). An ft-camd already running from ft-cutouts or ft-handsctl is used as it is. diff --git a/hands/rec/ft_handrec.py b/hands/rec/ft_handrec.py index 5af73aa..7376224 100755 --- a/hands/rec/ft_handrec.py +++ b/hands/rec/ft_handrec.py @@ -24,7 +24,9 @@ Everything lives under ~/.local/share/frametop/hands/contrib (--base). Nothing i unless the person presses Upload; while CONSENT.md or UPLOAD.md is a draft, Upload stays off unless FT_HANDREC_ALLOW_UPLOAD=1 (the maintainer's rehearsal against a test repo, picked with FT_HANDREC_DATASET). --hub-dry-run does everything but the network calls. -Launch with hands/rec/ft-handrec (host wrapper). +Launch with hands/rec/ft-handrec (host wrapper). The standalone Hand Recorder +(github.com/DeeJanuz/frametop-hand-recorder) runs this backend on the host, from a venv, with +its own window for SteamVR's dashboard: --qml its main.qml, --style Basic. """ import argparse import datetime @@ -292,6 +294,16 @@ class Backend(QObject): def consentText(self): return read_text(CONSENT_PATH) or "CONSENT.md is missing." + @Property(str, constant=True) + def toolVersion(self): + """What session.json records as the tool (takes.tool_version), for the window to show.""" + return takes.tool_version() + + @Property(bool, constant=True) + def standalone(self): + """The standalone Hand Recorder's install, not a Frametop checkout (takes.standalone).""" + return takes.standalone() is not None + @Property(str, constant=True) def consentVersion(self): return consent_version() @@ -1173,6 +1185,9 @@ def main(): help="test: Upload checks the export and says what it would send, with no network calls") ap.add_argument("--ignore-cameras", action="store_true", help="start sessions even if the camera check (hands/camcheck.py) finds the upper cameras off") + ap.add_argument("--qml", default=os.path.join(HERE, "main.qml"), + help="the window (default: this folder's main.qml, which needs Kirigami)") + ap.add_argument("--style", default="org.kde.desktop", help="the Qt Quick Controls style") a, qt_args = ap.parse_known_args() app = QGuiApplication([sys.argv[0]] + qt_args) app.setApplicationName("ft-handrec") @@ -1180,7 +1195,7 @@ def main(): app.setDesktopFileName("ft-handrec") if not QIcon.themeName(): QIcon.setThemeName("breeze") - QQuickStyle.setStyle("org.kde.desktop") + QQuickStyle.setStyle(a.style) store = takes.Store(a.base) engine = QQmlApplicationEngine() engine.addImageProvider("frames", FrameProvider(store)) @@ -1195,7 +1210,7 @@ def main(): app.aboutToQuit.connect(backend.shutdown) engine.rootContext().setContextProperty("backend", backend) engine.rootContext().setContextProperty("startPage", a.page) - engine.load(QUrl.fromLocalFile(os.path.join(HERE, "main.qml"))) + engine.load(QUrl.fromLocalFile(os.path.abspath(a.qml))) if not engine.rootObjects(): sys.exit(1) diff --git a/hands/rec/session.py b/hands/rec/session.py index 1bf2a59..7e256f5 100755 --- a/hands/rec/session.py +++ b/hands/rec/session.py @@ -51,6 +51,7 @@ sys.path.insert(0, HANDS) import camcheck # noqa: E402 (hands/camcheck.py: are all four mono cameras running?) sys.path.insert(0, HERE) import sides # noqa: E402 (hands/rec/sides.py: which side camera is which) +import takes # noqa: E402 (hands/rec/takes.py: the version, and the standalone recorder's build info) FT_HANDS = os.path.join(HANDS, "build", "ft-hands") FT_CAMD = os.path.join(HANDS, "build", "ft-camd") PANEL_BIN = os.path.join(HERE, "build", "ft-handpanel") @@ -60,6 +61,9 @@ BASE_DIR = os.path.expanduser("~/.local/share/frametop/hands/contrib") PANEL_SOCKET = "ft_handpanel" CAMD_UNIT = "frametop-handrec-camd.service" HANDS_UNIT = "frametop-handrec-hands.service" +# The standalone Hand Recorder (takes.standalone()): its binaries run on the host, so ft-hands +# starts directly rather than in the dev container, and repairs mean its install command. +STANDALONE = takes.standalone() RECORD_HZ = 10 SIDES_READ_S = 0.5 # how often the tracking ft-hands' side camera decision is read @@ -131,6 +135,13 @@ def host_command(*cmd): exe] + list(cmd) +def fix_hint(frametop): + """How to repair a missing or broken part: Frametop's command, or the standalone install's.""" + if STANDALONE: + return STANDALONE.get("reinstall") or "run the Hand Recorder's install command again" + return frametop + + def host_path(path): """A host file, from the container (/run/host) or the host itself.""" for p in ("/run/host" + path, path): @@ -369,10 +380,10 @@ def start_camd(path=None, log=lambda line: None, stop=lambda: False, timeout=15. if ring_alive(path): return False if not os.access(FT_CAMD, os.X_OK): - raise RuntimeError("ft-camd isn't built: hands/build.sh") + raise RuntimeError("ft-camd isn't built: " + fix_hint("hands/build.sh")) caps = subprocess.run(["getcap", FT_CAMD], capture_output=True, text=True) if shutil.which("getcap") else None if caps is not None and "cap_sys_ptrace" not in caps.stdout: - raise RuntimeError("ft-camd needs its capabilities: hands/run.sh caps (asks for sudo)") + raise RuntimeError("ft-camd needs its capabilities: " + fix_hint("hands/run.sh caps (asks for sudo)")) started = start_unit(CAMD_UNIT, "the camera broker", [FT_CAMD, "--status", "60"], log) end = time.monotonic() + timeout while time.monotonic() < end: @@ -614,7 +625,7 @@ class Recorder: "--sides", "auto" if swap is None else "1" if swap else "0"] if ring: argv += ["--ring", ring] - if not in_container(): + if not in_container() and not STANDALONE: argv = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--"] + argv self.proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log_file, stderr=log_file) self.started_ns = mono_ns() @@ -1117,15 +1128,6 @@ class _Redo(Exception): pass -def _git_describe(): - try: - r = subprocess.run(["git", "-C", REPO, "describe", "--always", "--dirty", "--tags"], - capture_output=True, text=True, timeout=5) - return r.stdout.strip() or "unknown" - except (OSError, subprocess.TimeoutExpired): - return "unknown" - - def _os_version(): path = "/run/host/etc/os-release" if in_container() else "/etc/os-release" try: @@ -1193,8 +1195,8 @@ def camera_text(result): return camcheck.USER_TEXT if camcheck.is_ring_short(result): return ("The headset's tracking cameras are all running, but the recorder can't read some of them (%s). " - "Close the Hand Recorder, run ~/frametop/hands/rec/install.sh again, and open it again. If that " - "doesn't help, ask in the Frametop Discord." % result.get("reason", "")) + "Close the Hand Recorder, %s, and open it again. If that doesn't help, ask in the Frametop " + "Discord." % (result.get("reason", ""), fix_hint("run ~/frametop/hands/rec/install.sh again"))) return ("Not all of the headset's tracking cameras are running (%s). Restart SteamVR, or restart the " "headset if that doesn't fix it." % result.get("reason", "")) @@ -1444,7 +1446,7 @@ class Session: self._ring_path = ring_path if not self.dry_run: if not os.access(FT_HANDS, os.X_OK): - raise _Fail("ft-hands isn't built: hands/build.sh") + raise _Fail("ft-hands isn't built: " + fix_hint("hands/build.sh")) self._ensure_ring(ring_path) if not tracker_running(): if self.start_processes: @@ -1462,7 +1464,7 @@ class Session: pass removed = self._write_calibration() + self._write_device() self._session_json = { - "schema": 1, "tool": "ft-handrec " + _git_describe(), "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "schema": 1, "tool": takes.tool_version(), "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "contributor": self.profile.get("contributor", ""), "lighting": lighting_record(self.lighting_choice, lighting), "checklist": self.checklist, @@ -1599,11 +1601,12 @@ class Session: raise _Stop() def _start_tracker(self): - up = os.path.join(REPO, "scripts", "container-up.sh") - if os.access(up, os.X_OK): - subprocess.run(host_command(up), capture_output=True, timeout=120) - argv = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--", FT_HANDS, - "--no-gestures", "--status", "0"] + argv = [FT_HANDS, "--no-gestures", "--status", "0"] + if not STANDALONE: # built in the dev container: it runs there + up = os.path.join(REPO, "scripts", "container-up.sh") + if os.access(up, os.X_OK): + subprocess.run(host_command(up), capture_output=True, timeout=120) + argv = [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--"] + argv if self.ring: argv += ["--ring", self.ring] self._start_unit(HANDS_UNIT, "hand tracking for feedback", argv) @@ -1626,7 +1629,7 @@ class Session: self._log("using the ft-handpanel that's running") return if not os.access(self.panel_bin, os.X_OK): - raise _Fail("ft-handpanel isn't built: hands/rec/build.sh") + raise _Fail("ft-handpanel isn't built: " + fix_hint("hands/rec/build.sh")) self._panel_proc = subprocess.Popen([self.panel_bin, "--watch-stdin"], stdin=subprocess.PIPE, stdout=self._log_file, stderr=self._log_file) end = time.monotonic() + 15 diff --git a/hands/rec/takes.py b/hands/rec/takes.py index eeffe95..75511d9 100755 --- a/hands/rec/takes.py +++ b/hands/rec/takes.py @@ -83,8 +83,26 @@ def find_zstd(): return next((p for p in ZSTD_PATHS if os.access(p, os.X_OK)), None) +def standalone(path=None): + """The standalone Hand Recorder's build info, or None in a Frametop checkout. Its release + (github.com/DeeJanuz/frametop-hand-recorder) ships this repo's tree with standalone.json at + the top: {"name", "version", "frametop": this repo's git describe, "reinstall": how to repair + an install}. Its binaries are built for the SteamOS host, so nothing runs in the dev container.""" + try: + with open(path or os.path.join(REPO, "standalone.json")) as f: + info = json.load(f) + except (OSError, ValueError): + return None + return info if isinstance(info, dict) else None + + def tool_version(): - """ft-handrec plus the checkout's git describe, for session.json and the manifest.""" + """ft-handrec plus the checkout's git describe, for session.json and the manifest. The + standalone Hand Recorder has no checkout: the describe it was built from, and its version.""" + info = standalone() + if info: + return "ft-handrec %s (%s %s)" % (info.get("frametop") or "unknown", info.get("name") or "standalone", + info.get("version") or "unknown") try: out = subprocess.run(["git", "-C", REPO, "describe", "--always", "--dirty", "--tags"], capture_output=True, text=True, timeout=5) diff --git a/hands/rec/tests/test_qml_backend.py b/hands/rec/tests/test_qml_backend.py index 66fab3a..22afd70 100644 --- a/hands/rec/tests/test_qml_backend.py +++ b/hands/rec/tests/test_qml_backend.py @@ -2,7 +2,8 @@ """main.qml against ft_handrec.Backend: every backend.name(...) the window calls is a slot, and every backend.name it reads is a property or a slot. A method that lost its @Slot shows up in QML only as "is not a function" when its button is pressed (2026-10-03: Export did nothing). -Needs PySide6 (the dev container); skipped without it. +Needs PySide6 (the dev container); skipped without it. FT_HANDREC_QML=PATH checks another +window against the same backend (the standalone Hand Recorder's, in its CI). python3 hands/rec/tests/test_qml_backend.py """ @@ -14,6 +15,7 @@ import unittest HERE = os.path.dirname(os.path.abspath(__file__)) REC = os.path.dirname(HERE) sys.path.insert(0, REC) +QML = os.environ.get("FT_HANDREC_QML") or os.path.join(REC, "main.qml") class QmlBackendTest(unittest.TestCase): @@ -25,7 +27,7 @@ class QmlBackendTest(unittest.TestCase): meta = self.meta = ft_handrec.Backend.staticMetaObject self.slots = {bytes(meta.method(i).name()).decode() for i in range(meta.methodCount())} self.props = {meta.property(i).name() for i in range(meta.propertyCount())} - with open(os.path.join(REC, "main.qml")) as f: + with open(QML) as f: self.qml = f.read() def test_calls_are_slots(self): diff --git a/hands/rec/tests/test_session.py b/hands/rec/tests/test_session.py index 037b33d..0256237 100644 --- a/hands/rec/tests/test_session.py +++ b/hands/rec/tests/test_session.py @@ -630,5 +630,61 @@ class ManyPartsTest(unittest.TestCase): self.assertEqual(r.summary["sets"], 120 + 30) +class StandaloneTest(unittest.TestCase): + """The standalone Hand Recorder's tree (standalone.json at the top): ft-hands runs on the + host, the version comes from the build info, and repairs point at its install command.""" + INFO = {"name": "frametop-hand-recorder", "version": "0.1.0", "frametop": "4ba49af", + "reinstall": "run the install command again"} + + def test_build_info(self): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "standalone.json") + self.assertIsNone(takes.standalone(path)) + with open(path, "w") as f: + json.dump(self.INFO, f) + self.assertEqual(takes.standalone(path), self.INFO) + with open(path, "w") as f: + f.write("[1]") + self.assertIsNone(takes.standalone(path)) + with mock.patch.object(takes, "standalone", return_value=self.INFO): + self.assertEqual(takes.tool_version(), "ft-handrec 4ba49af (frametop-hand-recorder 0.1.0)") + + def recorder_argv(self, standalone): + with tempfile.TemporaryDirectory() as d, mock.patch.object(session, "STANDALONE", standalone), \ + mock.patch.object(session, "in_container", return_value=False), \ + mock.patch.object(session.subprocess, "Popen") as popen: + session.Recorder(d, 1, 10, None, None) + return popen.call_args[0][0] + + def tracker_argv(self, standalone): + fake = mock.Mock(ring=None) + with mock.patch.object(session, "STANDALONE", standalone), \ + mock.patch.object(session.subprocess, "run") as run: + session.Session._start_tracker(fake) + return fake._start_unit.call_args[0][2], run + + def test_ft_hands_on_the_host(self): + self.assertEqual(self.recorder_argv(self.INFO)[0], session.FT_HANDS) + argv, run = self.tracker_argv(self.INFO) + self.assertEqual(argv[0], session.FT_HANDS) + self.assertIn("--no-gestures", argv) + run.assert_not_called() # no container to bring up + + def test_ft_hands_in_the_dev_container(self): + self.assertTrue(self.recorder_argv(None)[0].endswith("distrobox")) + argv, _ = self.tracker_argv(None) + self.assertEqual(argv[:4], [os.path.expanduser("~/.local/bin/distrobox"), "enter", "dev", "--"]) + self.assertEqual(argv[4], session.FT_HANDS) + + def test_fix_hint(self): + with mock.patch.object(session, "STANDALONE", self.INFO): + self.assertEqual(session.fix_hint("hands/build.sh"), "run the install command again") + text = session.camera_text({"status": "degraded", "reason": "2 of 4", "ring_missing": ["upper_left"]}) + with mock.patch.object(session, "STANDALONE", None): + self.assertEqual(session.fix_hint("hands/build.sh"), "hands/build.sh") + self.assertIn("run the install command again", text) + self.assertNotIn("~/frametop", text) + + if __name__ == "__main__": unittest.main()