31 Commits

Author SHA1 Message Date
mollusk 45e5d7ef37 feat(cli): remove --mic (microphone is out of scope)
pixelpass is a screen-share tool meant to be paired with a dedicated
voice app (Mumble, TeamSpeak, Discord, …) for two-way talk — it never
mixes a mic. The --mic flag was declared, shown in the host banner, and
documented as working, but was never wired into the gst pipeline (a
no-op). Removed the flag from Cli + HostOpts + into_host_opts, dropped
it from the banner capture summary, and replaced the README's "--mic
mixes the mic" claim with an explicit out-of-scope note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 21:15:45 -04:00
mollusk cd127a9704 feat(host): X11 capture backend + shared pipeline extraction
Extract the display-agnostic encode/mux tail out of wayland.rs into a new
host/pipeline.rs: CaptureHandle + lifecycle, audio routing setup, the gst
arg builder, the spawn, and Serve::bind now live there. Backends supply
only their video-source element args plus a post-spawn hook (Wayland uses
it to close its leaked pipewire fd; X11 passes a no-op). capture.rs
collapses to a thin dispatcher; its CaptureHandle enum is gone.

Add host/x11.rs: ximagesrc (use-damage=false show-pointer=true), whole
root window by default or a single window via --window (xwininfo
click-picker → xid). x11rb reads geometry for an info log, justifying the
previously-vestigial dep. No portal, no fd dance — capture starts
silently when the first viewer connects (the ticket is the access
control). Viewer is display-agnostic and unchanged.

Wire --no-hwencode for real (was a no-op): the shared tail now selects
x264enc(tune=zerolatency,ultrafast)/I420 vs vah264enc/NV12 and switches
the videoconvert target format to match. Applies to both backends.

deps.rs: check_host_binaries now takes &HostOpts and checks shared
elements for both backends, encoder by --no-hwencode, source per backend
(pipewiresrc/ximagesrc), and xwininfo only when X11 + --window. Install
hints added for x264enc, ximagesrc, xwininfo.

Verified: warning-free build; smoke test still passes (tail unchanged);
ximagesrc + both encoder tails produce mpv-decodable H.264 against an
Xwayland root. Interactive cross-machine end-to-end pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 20:48:50 -04:00
mollusk 0c9d8eb9f9 host: emit relay-only ticket (drop direct IP candidates)
The host ticket embedded every direct IP candidate the endpoint
discovered — on this machine that was 10 addrs, 7 of them useless
Docker-bridge gateways (172.16.0.0/12) plus LAN/public v4/v6. That
bloated the ticket to ~320 chars and leaked local network topology to
whoever received it.

Keep only the endpoint id + relay URL (~140 chars). The relay
coordinates hole-punching to a direct path after connect, so peer
reachability is unchanged; the direct addrs in the ticket only ever
shaved a moment off the first connection attempt, and n0 DNS discovery
already publishes the full addr keyed by id as a backstop.

Await endpoint.online() (15s cap) before building the ticket so the
relay URL is reliably populated; a relay outage degrades to a
possibly-incomplete ticket rather than a hang.

Experimental — isolated on feat/short-ticket pending an end-to-end
cross-machine connect test before merging to main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:17:38 -04:00
mollusk 7fa5d410f9 cli: drop stale "+ ffmpeg" from --help about string
The Wayland path moved from a shelled-out ffmpeg to an in-process
GStreamer pipeline back in the 2026-05-16/18 pivot, but the clap
`about` string still advertised ffmpeg. Now reads "P2P screen sharing
over iroh".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 15:44:02 -04:00
mollusk 8674f907f2 docs: sync README status with shipped audio + repair work
Per-app audio routing (--app), mic mixing (--mic), and --repair all
landed in recent commits but the README still listed the first and last
as stubs. Move them to Working, drop them from "Not yet working" (X11
capture is now the only remaining stub), and add an Audio section
documenting --app/--mic/--repair.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 15:37:23 -04:00
mollusk 25a5b597f7 repair: unload orphan pixelpass_capture_* sinks and paired loopbacks
Replaces the Phase-2 stub. Parses `pactl list short modules` for
`module-null-sink` entries whose `sink_name=pixelpass_capture_<pid>`
names a PID with no /proc/<pid>, and `module-loopback` entries whose
`sink=` names one of those orphan sinks. Unloads loopbacks first, then
sinks (mirrors Routing::shutdown order so PipeWire doesn't leave
zombie links).

Live PIDs — including this process and any other running pixelpass —
are skipped and reported. Same-tab parser is robust to multi-line
{ ... } argument blocks from other modules because continuation lines
never parse as a u32 module ID.

Verified with synthetic orphans against this build:
  - single dead orphan (sink + loopback) → both cleaned, count = 2
  - single live orphan (pid 1) → both preserved, message names the
    live count
  - mixed dead + live → dead pair cleaned, live pair preserved,
    output reports both

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 16:33:33 -04:00
mollusk 54ebe96ca1 host/audio: oscillate loopback on stream lifecycle (session 4 of 4)
Subscribe registry.global_remove so we know when routed stream nodes
vanish; drop them from routed_node_ids and emit LastRoutedStreamGone
on the N→0 transition. Tokio side re-runs `pactl load-module
module-loopback` with the same args as start, restoring the
default-sink monitor mirror so the viewer hears system audio again
instead of going silent when the routed app exits mid-session.

FirstRoutedStream now fires on every 0→N transition (not just the
first), so the pair oscillates cleanly: each app open/close cycle
unloads → re-loads the loopback.

Verified cross-machine 2026-05-22 16:29 EDT — host with Strawberry
picked, laptop viewer over mpv with YouTube playing on host as a
control. Strawberry audible on laptop, YouTube silent (route active).
Quit Strawberry → YouTube became audible (loopback restored).
Reopened Strawberry → routed again, YouTube dropped out (loopback
unloaded). Clean Ctrl+C teardown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 16:29:28 -04:00
mollusk a144665f41 host/audio: per-stream routing via libpipewire (session 3 of 4)
When opts.app is set, a dedicated OS thread runs a libpipewire
MainLoop, subscribes to the registry, and writes target.object to
the "default" metadata so WirePlumber reroutes matching streams to
our per-PID null-sink. Activation is now opts.app.is_some() OR the
existing PIXELPASS_AUDIO_VIA_NULL_SINK env var (kept for
no-filter dogfooding).

Threading: tokio side spawns a std::thread; the two sides bridge via
pipewire::channel for cmd→thread (Shutdown) and tokio::sync::mpsc
for event→tokio (FirstRoutedStream). Cross-thread quit goes through
the libpipewire channel so MainLoop is only mutated from its own
thread. Shutdown clears target.object on every routed stream before
quitting so WirePlumber doesn't log orphans.

Routing decisions:
- Filter is case-insensitive equality on application.name (predictable;
  no surprise matches from substring).
- target.object is written as Spa:Id with the sink's object.serial.
- Default-sink loopback stays loaded until the first stream is
  actually routed — avoids viewer silence if the user picks an app
  that isn't producing sound yet. On first route, the event task
  takes() the loopback module ID and unloads it.

Session 2 picker explainer + (app pick saved: ...) banner softening
both removed; banner is back to plain app-audio=NAME.

Verified end-to-end cross-machine: desktop host with Strawberry
selected, laptop viewer over mpv. Strawberry audible on the laptop;
YouTube playback started on the desktop was NOT audible on the
laptop. Routing isolates the filtered app.

Session 4 still open: recreate loopback when the last filtered stream
disappears (avoid silence), handle app-disappears-mid-session,
multi-instance, --repair coupling for orphan sink cleanup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:56:44 -04:00
mollusk 339a9d49e4 host/audio: app enumeration + interactive picker (session 2 of 4)
list_playing_apps() shells out to `pactl -f json list sink-inputs`,
parses with serde_json, dedupes by application.name (BTreeMap for
stable ordering), returns Vec<App { name, stream_count }>.

Picker fires in interactive::run after preflight, before host::run.
Bypassed when --app NAME is on the CLI. Shows the apps with a
"per-app routing isn't live yet" explainer so users aren't surprised
that audio still captures system-wide. Empty-list path shows the
default + a "start your app first" hint so the feature stays
discoverable.

Banner softened to `system-audio (app pick saved: <name>)` when
opts.app is set — keeps the choice visible without lying about what
gets captured. Routing activation still gated on the
PIXELPASS_AUDIO_VIA_NULL_SINK env var (session 1's locked decision
#2); --app flips to that activation in session 3 once per-stream
filtering exists.

Verified end-to-end interactively: Strawberry shows up in the picker
during music playback, both default and app-pick paths advance into
the portal handshake, banner matches choice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:32:00 -04:00
mollusk 8d32ded412 host/audio: per-PID PipeWire null-sink + loopback scaffolding
Session 1 of the per-app audio routing feature. Adds host/audio.rs
with a Routing struct that owns the lifecycle of two pactl-loaded
modules: a per-PID null-sink (pixelpass_capture_<pid>) and a loopback
mirroring @DEFAULT_SINK@.monitor into it at 20ms latency. Activated
by PIXELPASS_AUDIO_VIA_NULL_SINK=1 — kept hidden behind an env var
because without per-stream filtering (session 3) the user-facing
behavior of --app foo would be identical to no flag, which would
mislead users about what the flag does.

When the env var is set, wayland::start substitutes the gst pulsesrc
device from {DEFAULT_SINK}.monitor to pixelpass_capture_<pid>.monitor;
audio still works end-to-end via the loopback. CaptureHandle owns the
Routing alongside gst and serve; teardown order is gst → audio → serve
so streams unlink from the null-sink before the sink is destroyed.

Lifecycle is via pactl shell-outs rather than pipewire-rs. Null-sink
+ loopback are one-shot graph mutations with no event subscription;
the libpipewire route would mean dragging a MainLoop thread in for no
benefit until session 3 needs stream events.

Known cosmetic: the null-sink appears in Plasma's audio mixer as a
user-facing volume slider. Pactl's sink_properties= quoting is fiddly
enough that the device.hidden=true fix is parked for a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:11:48 -04:00
mollusk 9625511c65 cleanup: demote clean-disconnect warn, drop dead --low-latency flag
handle_peer's `bridge ended with error: ...` log fired at WARN every
time a viewer cleanly closed — but bridge can only end three ways
(peer-close, local-socket-close, cancellation), none of which are real
errors. Collapsed to INFO for both Ok and Err arms; the message itself
still carries any error detail.

Also removed the `--low-latency` CLI flag and its HostOpts field. It
was a placeholder for an unimplemented Phase-2/3 SRT transport, never
read anywhere, and was generating a persistent dead_code warning. If
SRT ever happens, the flag can come back fresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 03:50:20 -04:00
mollusk e1a018fdf7 host/serve: extract HTTP fanout from wayland.rs
The broadcast fanout, supervisor-facing listener bind, accept loop, and
per-viewer drain were all sitting inside host/wayland.rs even though
none of it is Wayland-specific. Move them to host/serve.rs so the X11
backend can share the same serving layer with a one-line constructor
call instead of copy-pasting (and drifting on) the fanout code.

No behavior change. Wayland's CaptureHandle now wraps a serve::Serve
instead of owning the listener/reader/server fields directly; gst
pipeline construction is unchanged. connect_to_capture moves alongside
Serve since it pairs with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 02:32:35 -04:00
mollusk f939441e31 README: document multi-viewer + bandwidth pre-flight
Updates the status section to move multi-viewer out of "not yet
working", adds a Configuration section pointing at the new TOML config
at ~/.config/pixelpass/config.toml, and a Multi-viewer section
covering the lazy-sticky lifecycle, the --max-viewers cap, the
bandwidth-bitrate tradeoff, and how to fit more viewers by dropping
--bitrate. Known-limitations section gains "late joiners see ~2 s of
garbage" (expected behavior) and drops the now-stale "single viewer
per host" line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 17:00:58 -04:00
mollusk 153febe078 pre-flight: bandwidth test + persistent config
First-run host launch now offers a one-time upstream measurement
against speed.cloudflare.com/__up via ureq (~5 MB POST, ~5s). The
result lives at ~/.config/pixelpass/config.toml under [bandwidth]
and feeds the default --max-viewers calculation on subsequent runs.

Sticky semantics for the dialog:
- Unmeasured: first-run prompt (Run / Skip)
- Measured / Skipped: silent — never re-prompts
- Failed: ask again on next launch (Retry / give up → Skipped)

`pixelpass --reconfigure` re-runs the test unconditionally for users
whose connection has changed (new ISP, moved house, etc.).

--max-viewers is now Option<u32>. When unset, host startup loads the
saved measurement, runs recommended_max_viewers(safe_mbps, bitrate),
and surfaces the source in the banner: "max viewers : N (auto: X.X
Mbps measured upstream)" — or user-specified / default fallback.

User verified end-to-end on 2026-05-21 16:54 EDT: first-run dialog,
skip path, run path, --reconfigure refresh, and banner integration
all work as expected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:55:11 -04:00
mollusk ffe5a90686 multi-viewer: broadcast fanout + supervisor lifecycle
One gst capture pipeline now fans out to N concurrent viewers via a
tokio::sync::broadcast<Arc<Vec<u8>>>. The HTTP listener accepts forever;
each accepted connection spawns a sender task draining its own
broadcast::Receiver. Slow consumers see Lagged and skip ahead — MPEG-TS
resyncs at the next keyframe.

Host runtime is now lazy + sticky: a supervisor task owns the capture
handle and viewer count. First viewer triggers capture::spawn; last
viewer triggers shutdown. Subsequent reconnects re-trigger the portal
dialog as expected. --max-viewers (default 2) caps concurrent viewers;
additional connections get a "host is full" refusal and are dropped.

Banner updated to reflect the new lifecycle and viewer cap.

NOT YET RUNTIME-VERIFIED. cargo build is clean and the pipeline-level
smoke test still passes, but the multi-viewer behavior (cap enforcement,
lazy-sticky restart, concurrent fanout) requires manual end-to-end
testing with the portal dialog + multiple mpv instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 16:11:43 -04:00
mollusk 74b4101d4f vlc-plugin-ffmpeg: extend docs + runtime check
The previous vlc-plugin-dvb diagnosis was incomplete. On a laptop with
only vlc-plugin-dvb installed, VLC reads the MPEG-TS container, sees
the H.264 stream type in the PMT, then errors "Codec h264 ... is not
supported" because libavcodec_plugin.so is also a split package and
also wasn't pulled in by the base `vlc` install.

Installing vlc-plugin-ffmpeg (which pulls ffmpeg4.4 as a compat dep)
on the laptop made VLC play pixelpass cleanly via Intel iHD hardware
decode.

- README: list both plugin packages under requirements; rewrite the
  known-limitations line.
- interactive.rs: extend the launch-time check to also probe for
  libavcodec_plugin.so; combine both into one warning that lists
  every missing piece and the single pacman invocation to fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 06:21:32 -04:00
mollusk 6e4d30bfa9 vlc-plugin-dvb: document and warn at launch
VLC's MPEG-TS demuxer (libts_plugin.so) ships in a separate package on
Arch / CachyOS (vlc-plugin-dvb). Without it, VLC silently falls back
to the PS demuxer and misidentifies our H.264 stream — the symptom is
a green screen. mpv doesn't share this dependency.

- README: list vlc-plugin-dvb under requirements, replace the
  "green screen, not yet diagnosed" gotcha with the diagnosis.
- interactive.rs: when the user picks VLC, check for
  /usr/lib/vlc/plugins/demux/libts_plugin.so and print a warning to
  stderr if it's missing. Soft warning, not a hard error — VLC still
  spawns so the user can confirm the symptom for themselves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 05:28:19 -04:00
mollusk 15766834f1 Revert "viewer HTTP: Content-Type application/octet-stream, not video/mp2t"
This reverts commit 0a253bd919.

The Content-Type change was a misdiagnosis. The real cause of VLC's
"no demux modules matched" was a missing `vlc-plugin-dvb` package on
the test machine — Arch/CachyOS ship the MPEG-TS demuxer plugin
(`libts_plugin.so`) in a separate package from `vlc`. Without it, VLC
falls through to the PS demuxer and misidentifies the H.264 stream.
With the package installed, `video/mp2t` opens cleanly.

`video/mp2t` is the correct Content-Type for an MPEG-TS stream and is
what we should be sending. Documentation of the package requirement
and a runtime check follow in a separate commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 05:26:53 -04:00
mollusk 0a253bd919 viewer HTTP: Content-Type application/octet-stream, not video/mp2t
VLC parses Content-Type before invoking the demuxer chain. With
video/mp2t it commits to demux="ts" by MIME alone, bypassing
byte-probing; when the ts demuxer's Open fails on the live HTTP stream
("no demux modules matched"), the input never opens. mpv probes
regardless of Content-Type.

Reproduced deterministically with a Python shim that mimics our
response headers byte-for-byte: only the Content-Type matters.
Changing it to application/octet-stream (or any non-video MIME, or
omitting the header) makes VLC fall back to byte-probing, which
finds the TS sync pattern and opens cleanly. mpv unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:30:46 -04:00
mollusk 3aa8d73ea0 Cargo.toml: drop ffmpeg from package description
ffmpeg was removed from the Wayland path on 2026-05-16 (commit 7b8b6bc).
The description was stale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:07:48 -04:00
mollusk 8619df10d5 Add README
Covers v0.1 status, quick-start (interactive + headless), system
deps, build, architecture diagram, design rationale, and known
limitations. No README existed before — this fills the gap now that
v0.1 is verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:04:36 -04:00
mollusk 858540c3c5 viewer: default mpv to --hwdec=auto
Software H.264 decode at 1080p struggled on a low-power viewer (Intel
UHD 620 laptop on battery / power-save governor), surfacing as choppy
motion and A-V drift even though the host encode side was clean. mpv's
auto hwdec picks vaapi/nvdec/etc. when available and silently falls
back to software when not, so the default is strictly safer.

Applied to both the headless banner recipe and the interactive
player-picker spawn args.
v0.1.0
2026-05-18 17:10:36 -04:00
mollusk bb437a73cf interactive: Host/View entry menu, clipboard copy, player picker
Bare `pixelpass` now opens a dialoguer-driven Host/View menu instead of
going straight to host mode. Host path copies the ticket to the system
clipboard via arboard with silent print-only fallback. View path
prompts for the ticket, then after the local listener binds prompts
mpv-vs-VLC and spawns it detached (setsid + null stdio) so the player
survives pixelpass exiting.

Headless invocations (`pixelpass <ticket>`, `pixelpass --repair`)
unchanged. Per spec at ~/Documents/pixelpass-interactive-mode-spec.md.
2026-05-18 15:46:57 -04:00
mollusk 4cab9f6b20 audio: capture default sink's monitor, not default source
`pulsesrc` with no `device=` reads PulseAudio's default source —
which is the user's microphone, not system audio output. The stream
was technically working but the laptop was hearing the desktop's
mic (or silence on systems without one) instead of system audio.

At host startup, shell out to `pactl get-default-sink` to discover
the current default sink, then pass `device=<sink>.monitor` to
pulsesrc. Resolving at session-start covers users who switch outputs
(speakers vs headset vs HDMI) between sessions. pactl added to the
host's required-binary list.

Verified cross-machine: audio came through clearly with the prior
~1s latency floor preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:42:42 -04:00
mollusk 7983701b03 Fix latency drift: cap framerate + tune mpv buffers
Symptom: cross-machine streams drifted ~6-9 seconds per minute. mpv
showed continuous "Audio device underrun detected" with video packets
piling up in its demuxer queue while audio queue stayed at 0.

Root causes:
- pipewiresrc captured at the host monitor's refresh rate (180Hz),
  not at the configured framerate. The encoder + mux produced 6x more
  frames per wallclock second than mpv could consume at realtime,
  burying audio packet density in mpv's demuxer queue.
- `--profile=low-latency` sets `audio-buffer=0`, which is too
  aggressive — any sub-millisecond network jitter immediately
  starves the audio device. Underruns slowed mpv's audio clock, and
  with `video-sync=audio` (also from low-latency profile) video
  followed.

Fixes:
- `videorate ! video/x-raw,framerate=<fps>/1` after pipewiresrc to
  cap input to the requested rate deterministically.
- mpv command grows `--audio-buffer=0.2 --demuxer-max-bytes=2M
  --demuxer-readahead-secs=0.5`: small audio buffer to absorb network
  jitter, demuxer cap to prevent runaway buildup.

A leaky-queues attempt landed and was reverted in the same commit —
it removed backpressure without addressing the root cause and made
things worse.

Verified cross-machine 6m51s: drift held at ~1s floor, zero audio
underruns, perfect A-V sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:25:17 -04:00
mollusk d3bff65add tunnel: keep data direction alive when reverse direction EOFs
The old tokio::select! tore down the whole bridge when EITHER
direction's io::copy finished. For a one-way streaming workload the
reverse direction carries only the initial HTTP GET; once mpv stops
sending and the read half EOFs, the data direction got killed mid-
stream and the host logged "bridge closed cleanly" while the user's
video disappeared.

Spawn the reverse direction as a detached task and `.await` only
the data direction. When the data direction ends naturally, abort
the reverse task. The function gains Send + 'static bounds on T,
which TcpStream satisfies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:30:04 -04:00
mollusk 7b8b6bcd0c Wayland: VAAPI H.264 + in-process HTTP server, drop ffmpeg
The previous ffmpeg-as-HTTP-server pipeline shape held back two
improvements at once. ffmpeg as the runtime server lost a one-shot
`-listen 1` accept to a probe-and-discard health check, and forced
us to size analyze/probe budgets carefully so ffmpeg would serve
before our deadline. Replacing it with a small tokio task that
accepts once, drains the HTTP request, writes a fixed 200 OK, then
`tokio::io::copy`s gst stdout to the socket removes all of that.

VAAPI H.264 (vah264enc) drops CPU encode from ~50% of a core to
single-digit %. An earlier attempt at vaav1enc had to be abandoned:
libavformat cannot demux AV1-in-MPEG-TS with the custom mapping
even with a 20MB probe budget — mpv reports video=eof. H.264 keeps
the hardware win on the well-trodden demuxer path.

scripts/smoke-pipeline.sh mirrors the runtime pipeline with
videotestsrc/audiotestsrc into a file and asserts that mpv reports
`video=playing` (not video=eof). The naive --frames=10 check was
a false positive when no video stream is recognized; the verbose
grep is the real gate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:29:56 -04:00
mollusk 75a01a361e Working Wayland end-to-end: gst owns the pipeline, ffmpeg just serves
Moves the full capture+encode+mux pipeline into gst-launch, leaving
ffmpeg as a thin HTTP server. Verified end-to-end on KDE Plasma 6
Wayland: screencast portal → mpv mirror-tunnel rendering in real time.

Pipeline:
  pipewiresrc(do-timestamp) → videoconvert → x264enc (zerolatency
    ultrafast) → h264parse(config-interval=-1) → byte-stream caps →
    mpegtsmux ← (aacparse ← avenc_aac ← audioconvert ← pulsesrc) →
    fdsink fd=1
  ffmpeg -fflags nobuffer+discardcorrupt+genpts -flags low_delay
    -analyzeduration 0 -probesize 32 -f mpegts -i pipe:0 -c copy
    -f mpegts -listen 1 http://127.0.0.1:N

Why each piece is load-bearing (do not relitigate without cause):

- x264enc + h264parse + byte-stream caps: raw video over a pipe hits
  stride/format negotiation problems (green screens with mis-aligned
  rows). Encoding inside gst sidesteps that entirely.
- mpegtsmux inside gst: H.264 Annex B carries no timestamps. Without
  a container, ffmpeg sees "Timestamps are unset" and downstream
  muxing breaks. mpegts in gst preserves pipewiresrc's clock.
- byte-stream + alignment=au caps: h264parse defaults to AVC format
  (length-prefixed NALUs) for some downstreams; ffmpeg's mpegts
  demuxer needs Annex B start codes.
- audio in gst (pulsesrc + avenc_aac): keeping ffmpeg as a pure
  passthrough (`-c copy`) avoids ffmpeg's audio-input dependency
  delaying HTTP serving until both inputs are ready.
- `-analyzeduration 0 -probesize 32`: stop ffmpeg from buffering 5MB
  / 5s of input before deciding it understands the stream.
- Also fixes a separate one-shot bug from earlier: the previous
  health-probe in wait_for_listener consumed ffmpeg's single
  `-listen 1` accept slot, so the actual bridge connect hit
  Connection refused. Replaced with connect_to_capture which
  returns the bridge socket.

Adds dep checks for pipewiresrc, x264enc, h264parse, mpegtsmux,
pulsesrc, avenc_aac, aacparse with per-distro install hints.

Known gap: VLC currently shows a green screen against the stream
even though mpv works fine. Likely VLC-specific demuxer/latency
settings, not a pipeline correctness issue — to investigate as a
follow-up. mpv is the recommended client either way.
2026-05-15 16:43:23 -04:00
mollusk c028e39aba Detect missing pipewiresrc element before host startup
`gst-launch-1.0` and `pipewiresrc` ship in separate packages on most
distros (Arch: `gst-plugin-pipewire`, Debian: `gstreamer1.0-pipewire`,
Fedora/openSUSE: `pipewire-gstreamer`). Having the gst binary present
was no guarantee the Wayland capture pipeline would actually work —
without the plugin gst would bail at runtime with `no element
"pipewiresrc"`, which then cascaded into ffmpeg seeing EOF on its
stdin and exiting before its HTTP listener bound, then the host
hitting "Connection refused" against its own port. Confusing.

Now `deps::check_host_binaries` probes `gst-inspect-1.0 --exists
pipewiresrc` on Wayland and fails early with a per-distro install
hint pointing at the right package.
2026-05-15 15:34:28 -04:00
mollusk 3a551c2287 Wayland capture: ashpd portal → gst-launch → ffmpeg → MPEG-TS
Implements the Wayland host pipeline from plan §4.5:

  ashpd ScreenCast portal
    -> CreateSession + SelectSources + Start + OpenPipeWireRemote
    -> (pipewire fd, node_id, width, height)
  gst-launch-1.0 pipewiresrc fd=N path=NODE_ID ! videoconvert
    ! video/x-raw,format=NV12 ! fdsink fd=1
  ffmpeg
    -f rawvideo -pix_fmt nv12 -video_size WxH -i pipe:0
    -f pulse -i default
    -c:v libx264 -preset ultrafast -tune zerolatency
    -c:a aac -f mpegts -listen 1 http://127.0.0.1:<rand>

Phase 1 ships software x264 per plan §7; VAAPI is Phase 2.

src/host/wayland.rs is the new module. capture.rs becomes a thin
dispatcher with a CaptureHandle enum (Wayland today, X11 next).
host/mod.rs swaps the 150ms sleep for a poll-until-listener-ready
helper, and calls handle.shutdown().await for an orderly SIGTERM /
1s grace / SIGKILL teardown. The Drop impl is the panic backstop.

The pipewire fd handoff clears CLOEXEC before gst-launch spawn and
closes the parent's copy of the raw fd after the child has it.

Also deletes the empty src/host/tunnel.rs and src/viewer/tunnel.rs
placeholder files — the generic bridge in common/tunnel.rs is doing
the work, and there's no host- or viewer-specific tunnel concern
worth a module yet.
2026-05-15 15:22:35 -04:00
mollusk 6ad92081aa Phase 1 foundation: CLI, iroh tunnel, lazy capture wiring
Scaffolding for PixelPass per ~/Documents/p2p-screenshare-plan.md §7
"Phase 1 — MVP". The full QUIC tunnel handshake works end-to-end
(verified locally: host generates ticket, viewer dials through iroh's
relay, open_bi succeeds, lazy capture is wired correctly).

What's implemented:

- Cargo project with deps locked: iroh 1.0.0-rc.0, iroh-tickets,
  tokio, clap, ashpd, pipewire-rs, x11rb, ashpd, anyhow, thiserror,
  tracing, nix, directories, uuid.
- src/cli.rs: complete clap surface per plan §6 (--window, --app,
  --mic, --display-server, --bitrate, --framerate, --no-hwencode,
  --low-latency, --port, --verbose, --repair).
- Mode dispatch in main.rs: EndpointTicket::from_str is the
  authoritative check; no regex / heuristics.
- common/display.rs: WAYLAND_DISPLAY → DISPLAY → XDG_SESSION_TYPE
  precedence with --display-server override.
- common/deps.rs: per-distro install hints (pacman/apt/dnf/zypper)
  parsing /etc/os-release.
- common/alpn.rs: ALPN = b"pixelpass/0".
- common/tunnel.rs: generic bidirectional bridge between an iroh
  bi-stream and any AsyncRead+AsyncWrite (typically a TCP socket).
- common/signal.rs: ctrl-c -> CancellationToken; second ctrl-c hard
  exit.
- host/mod.rs: build Endpoint, generate ticket, print banner, await
  first peer (lazy — no ffmpeg until peer connects), accept_bi,
  spawn capture, bridge to localhost ffmpeg HTTP listener.
- host/capture.rs: stub returning Phase-2 error; the place X11
  x11grab and Wayland ashpd+gst pipelines will land.
- viewer/mod.rs: Endpoint, connect with ALPN, open_bi, TcpListener
  on 127.0.0.1, print copy-ready mpv/vlc commands, bridge.
- repair.rs: stub for --repair PipeWire scan.

iroh 1.0-rc renamed Node* -> Endpoint* and moved EndpointTicket into
a sibling crate (iroh-tickets); no design impact. Plan still locked.
2026-05-15 15:13:28 -04:00