Skip to content

feat(bridge): live phone-camera mode — desktop (#49) - #58

Open
yb85 wants to merge 5 commits into
mainfrom
feat/49-bridge-live
Open

feat(bridge): live phone-camera mode — desktop (#49)#58
yb85 wants to merge 5 commits into
mainfrom
feat/49-bridge-live

Conversation

@yb85

@yb85 yb85 commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Desktop half of the live phone-camera feature (#49) — the "Alternative" path (no Continuity Camera). The iOS half is yb85/aglaia-bridge#.

What's here

  • bridge_live.pyBridgeLiveServer: a persistent token-gated HTTPS session over the same stdlib ThreadingHTTPServer + ephemeral-cert/fingerprint-pin stack as the PRD: aglaia-bridge v1 — iOS capture + QR-bootstrapped TLS-pinned handoff #47 receiver (no new deps). Phone streams preview frames (POST /v1/frame), long-polls the shutter (GET /v1/command), returns a full-res still (POST /v1/still). Protocol bridge-live/1.
  • BridgeCameraThread — a WebcamThread look-alike, so MainWindow.capture(), voice, zoom, and DPI calibration work unchanged; stills tagged source_ref="bridge#<device>". Zoom is a desktop-side digital crop (effective_dpi = base × zoom intact).
  • BridgeTab + BridgeLiveController — 6th sidebar tab: QR pairing ↔ live capture swap, arm/disarm on tab entry, mutual exclusion with the local webcam.
  • DPI calibration freeze-frame — live framing on the low-res preview, one full-res still pulled only to measure.
  • tools/fake_bridge_phone.py — device-free E2E driver (right-click the QR → "Copy pairing URI").
  • docs/bridge.md — authoritative bridge-live/1 spec, trust model, failure matrix, live-device checklist; gui/architecture/README updated.

Trust model

QR = trusted side channel, LAN = hostile → ephemeral TLS + fingerprint pin (server auth) + single-use bearer token. Fresh cert + token each pairing.

Tests

29 new tests (server/protocol 11, camera thread 7, tab/wiring 6, DPI dialog 5). Full suite 427 passed / 10 skipped. Device-free E2E verified end to end (fake phone → 3840×2880 still through the real server).

Remaining

Live end-to-end on a real device pair (checklist in docs/bridge.md) — the only unverified path.

Closes #49

🤖 Generated with Claude Code

yb85 and others added 5 commits July 7, 2026 11:04
Slice 1 of the phone-as-camera feature. Adds BridgeLiveServer — a
persistent token-gated HTTPS session (stdlib ThreadingHTTPServer + ssl,
no new deps) over the same ephemeral-cert/fingerprint-pin trust triad as
the one-shot receiver: the phone streams low-res preview frames
(POST /v1/frame), long-polls for a desktop shutter (GET /v1/command), and
returns a full-res still (POST /v1/still).

- ReceiverInfo.qr_uri(mode="live") → aglaia://v1?…&m=live; old push app
  hitting /import on a live server gets a graceful 404 hint.
- FakePhone test client drives the whole protocol over pinned TLS;
  11 tests (409 conflict, still round-trip, poll timeout, liveness, bye).
- docs/bridge.md: authoritative bridge-live/1 spec + failure matrix +
  live-device checklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 2. Extracts WebcamThread's rotate/mirror/flip into _apply_transform
(shared by the real + fake loops), then adds BridgeCameraThread: a
WebcamThread subclass that emits phone preview frames via
change_pixmap_signal and whose get_frame() fetches a remote full-res still
(BridgeLiveServer.request_still). Because it duck-types the webcam thread,
MainWindow.capture()/voice/zoom work unchanged.

Zoom is a desktop-side digital crop (center-crop 1/zoom, resize back up:
LINEAR for preview, CUBIC on the still output path) so effective_dpi =
base × zoom stays correct with no wire command. get_frame has a ~1s
freshness cache and degrades to None + session_lost/still_failed signals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 4. Adds a 6th sidebar tab that pairs a phone (QR) then hosts a live
CaptureTab backed by BridgeCameraThread:

- BridgeTab (QR ↔ live QStackedWidget; right-click QR → Copy pairing URI)
  + BridgeLiveController (marshals server callbacks to Qt signals).
- MainWindow: arm/disarm on tab entry (tab_changed); _make_live_capture_tab
  (bridge=True) hides camera/format/freehand + relabels Deactivate → "End
  bridge session"; session start builds the live tab and repoints
  _capture_tab so update_image/DPI route to it; teardown restores it and
  re-arms a fresh QR. Mutual exclusion with the local webcam. capture()
  tags bridge stills source_ref="bridge#<device>". closeEvent disarms
  (queues bye).
- CaptureTab: camera/format rows wrapped in hideable containers.
- tools/fake_bridge_phone.py: device-free E2E driver (streams an image as
  preview, answers shutters with the full-size still). Verified end to end.
- 6 gui tests (tab swap, picker hiding, mutual exclusion, start/teardown).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice 7. The DPI dialog's 60 ms live tick now reads get_preview_frame()
when the source exposes it (a bridge), so framing/quad-overlay/steadiness
run on the low-res stream and never trigger a remote still. Only a
measurement — capture-refine, manual trace, or ruler — pulls one full-res
still via _grab_measure_frame(), and the card DPI is always computed on
those sensor pixels. The live ≈dpi readout is lifted by the
still/preview long-edge ratio (_measure_scale) to approximate real DPI;
scale-invariant, so the steadiness gate is unaffected. A local webcam is
unchanged (get_frame is already full-res, scale 1.0).

5 tests: live ticks pull 0 stills, capture-refine/ruler pull exactly 1,
measure-scale math, webcam fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gui.md gains a Bridge-tab section; architecture.md lists the bridge-live
producer; README + the CLAUDE.md pointer table link docs/bridge.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(bridge): live mode — phone streams preview, desktop-triggered full-res stills

1 participant