diff --git a/artifacts/SHA256SUMS b/artifacts/SHA256SUMS index c277ce0b..5ef08450 100644 --- a/artifacts/SHA256SUMS +++ b/artifacts/SHA256SUMS @@ -12,6 +12,6 @@ c10412dd76a65a1d587b7ed226367d1a6f2bec238c733c97ee67eb8870c868e8 ./armeabi-v7a/ 1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex 1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json 28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so -33a4060010a9d2c5d1515fa2d5294beb664edeaef7d37f4d325ab6e6ce6a5f0e ./linux-arm64-bin/fp_venc +a4a6d9f41c290a421842294a39562e1e6f0ca271bb47b72f615c808560338f6a ./linux-arm64-bin/fp_venc c2c70f4af1a5c3e6089dff1130161dd417f8044f99ec639eeecf1083930aab16 ./win-x64/fp_oculushmd.exe 60ebc5fea05b8945082e2ca8b9f2eb74616012a26eb50133af273dff0fcddc42 ./win-x64/fp_vrsettings.exe diff --git a/artifacts/linux-arm64-bin/fp_venc b/artifacts/linux-arm64-bin/fp_venc index 7402062c..696ccfd0 100644 Binary files a/artifacts/linux-arm64-bin/fp_venc and b/artifacts/linux-arm64-bin/fp_venc differ diff --git a/docs/FRAME_RUNTIME.md b/docs/FRAME_RUNTIME.md index 818d6692..63aa9ce5 100644 --- a/docs/FRAME_RUNTIME.md +++ b/docs/FRAME_RUNTIME.md @@ -160,6 +160,20 @@ - The panel's current refresh rate can be read without privileges through DRM: `/dev/dri/card0` is mode 0666, and GETCRTC reports e.g. `2*2160x2160_96` (clock 1402720 kHz / 4448 × 3285 = 96 Hz) even while the headset sleeps. The panel offers 72/80/90/96/108/120/144 Hz. + - `/dev/video99` (v4l2loopback) has `max_buffers=2`: a reader asking for more gets 2. + - Mid-stream keyframe requests (FORCE_KEY_FRAME) take effect on the next frame, and the GOP restarts from there. + - Measured 2026-10-07 with the headset asleep (still picture): + - `fp_venc` alone at 32/36 fps: 1% of a core at 1080p, 2.5% at 720p. + - Live view end to end: `fp_venc` 2.6% + ffmpeg 8.4% (AAC encoding + muxing). + - Conversion cost per new picture (self-test, NEON): 1.5 ms at 1080p→1080p. Box-scaling to 720p takes 5.6 ms + (~11 ms at idle clocks), so with live content 1080p costs the CPU *less* than the scaled qualities. + - ffmpeg with raw H.264 on a pipe: + - `-framerate` is ignored. + - `-fflags nobuffer` loses the first seconds of tiny frames. + - Numbering frames from 0 (`setts`) next to pulse's wall-clock audio holds all output ~7 s. + - What works: `-probesize 32 -analyzeduration 0 -use_wallclock_as_timestamps 1`, plus `frag_keyframe` so that a + requested keyframe starts a fragment. + - The default sink is SUSPENDED while nothing plays; its monitor still delivers (silent) audio. ## Text input diff --git a/native/venc/fp_venc.c b/native/venc/fp_venc.c index 0eb852c4..a6f606e4 100644 --- a/native/venc/fp_venc.c +++ b/native/venc/fp_venc.c @@ -350,6 +350,8 @@ struct enc { uint8_t *out_mem[ENC_BUFS], *cap_mem[ENC_BUFS]; uint32_t out_len[ENC_BUFS], cap_len[ENC_BUFS]; int out_free[ENC_BUFS]; + long out_pic[ENC_BUFS]; /* which picture each OUTPUT buffer holds (0 = black, -1 = none): a repeat that finds + * its picture already in the buffer skips the conversion (standby, still scenes) */ }; static int src_open(struct src *s, const char *path) @@ -375,8 +377,10 @@ static int src_open(struct src *s, const char *path) memset(&rb, 0, sizeof rb); rb.count = SRC_BUFS, rb.type = V4L2_BUF_TYPE_VIDEO_CAPTURE, rb.memory = V4L2_MEMORY_MMAP; r = sys_ioctl(s->fd, VIDIOC_REQBUFS, &rb); - if (r < 0 || rb.count < SRC_BUFS) return fail(3, "source VIDIOC_REQBUFS", r < 0 ? r : -ENOMEM); - for (int i = 0; i < SRC_BUFS; i++) { + /* The driver may grant fewer (the Frame's v4l2loopback has max_buffers=2); two are enough: one held, one filling. */ + if (r < 0 || rb.count < 2) return fail(3, "source VIDIOC_REQBUFS", r < 0 ? r : -ENOMEM); + if (rb.count > SRC_BUFS) rb.count = SRC_BUFS; + for (int i = 0; i < (int)rb.count; i++) { struct v4l2_buffer b; memset(&b, 0, sizeof b); b.index = (uint32_t)i, b.type = V4L2_BUF_TYPE_VIDEO_CAPTURE, b.memory = V4L2_MEMORY_MMAP; @@ -710,6 +714,8 @@ int main(int argc, char **argv, char **envp) const int64_t t0 = now_ns(), period = 1000000000 / fps; int64_t next_stats = t0 + 10000000000LL, quiet_src = 0, quiet_enc = 0; long slot = 0; /* next slot to submit; due at t0 + slot * 1e9 / fps */ + long pic = 0; /* current picture: 0 = black (no source frame yet), +1 per new source frame */ + for (int i = 0; i < ENC_BUFS; i++) e.out_pic[i] = -1; int want_key = 0, stdin_open = 1, sent_any = 0, rc = 0; for (;;) { @@ -726,12 +732,16 @@ int main(int argc, char **argv, char **envp) while (i < ENC_BUFS && !e.out_free[i]) i++; if (i == ENC_BUFS) break; uint8_t *m = e.out_mem[i]; - if (s.held >= 0) { - fp_convert(s.mem[s.held], s.w, s.h, s.stride, m, e.stride, m + (size_t)e.stride * e.hpad, e.stride, - ow, oh, scratch); - } else { /* no source frame yet: black */ - memset(m, 16, (size_t)e.stride * oh); - memset(m + (size_t)e.stride * e.hpad, 128, (size_t)e.stride * oh / 2); + if (s.held >= 0 && s.fresh) pic++; + if (e.out_pic[i] != pic) { + if (s.held >= 0) { + fp_convert(s.mem[s.held], s.w, s.h, s.stride, m, e.stride, m + (size_t)e.stride * e.hpad, + e.stride, ow, oh, scratch); + } else { /* no source frame yet: black */ + memset(m, 16, (size_t)e.stride * oh); + memset(m + (size_t)e.stride * e.hpad, 128, (size_t)e.stride * oh / 2); + } + e.out_pic[i] = pic; } if (!s.fresh && sent_any) st.repeats++; s.fresh = 0; diff --git a/src/frameport/install/livestream.py b/src/frameport/install/livestream.py index 10c51444..d64970e2 100644 --- a/src/frameport/install/livestream.py +++ b/src/frameport/install/livestream.py @@ -428,11 +428,15 @@ info= hwfps=$(printf %s "$info" | sed -n 's/.*"fps":\\([0-9][0-9]*\\).*/\\1/p') if [ -n "$hwfps" ]; then echo "{INFO_PREFIX}encoder=hardware fps=$hwfps" >&2 - # fp_venc reads the SSH channel (stdin): "k" = keyframe, EOF = stop; ffmpeg then ends with it (-shortest) + # fp_venc reads the SSH channel (stdin): "k" = keyframe, EOF = stop; ffmpeg then ends with it (-shortest). + # Raw H.264 has no timestamps: frames are stamped on arrival (wall clock, like pulse's audio; fp_venc sends them on + # its even fps grid, so arrival = capture + a constant). Measured on the Frame (2026-10-07): -framerate is ignored; + # numbering frames from 0 (setts) held all output ~7 s when sound was on (interleaving against pulse's wall clock); + # -fflags nobuffer lost the first seconds of a still picture's tiny frames; a big probesize waits seconds for one. + # frag_keyframe: a requested keyframe starts its own fragment, where a joining viewer can begin. "$venc" {venc_args} --fps "$hwfps" | nice -n 10 ffmpeg -nostdin -hide_banner -loglevel error \\ - -probesize 262144 -analyzeduration 500000 -fflags +genpts+nobuffer -f h264 -framerate "$hwfps" -i pipe:0 \\ - "${{audio[@]}}" -c:v copy -shortest \\ - -f mp4 -movflags empty_moov+default_base_moof -frag_duration 100000 - + -probesize 32 -analyzeduration 0 -use_wallclock_as_timestamps 1 -f h264 -i pipe:0 "${{audio[@]}}" \\ + -c:v copy -shortest -f mp4 -movflags empty_moov+default_base_moof+frag_keyframe -frag_duration 100000 - exit $? fi echo "{INFO_PREFIX}encoder=software fps={fps}" >&2 diff --git a/tests/test_livestream.py b/tests/test_livestream.py index 2a72aefa..6a0de6b0 100644 --- a/tests/test_livestream.py +++ b/tests/test_livestream.py @@ -243,7 +243,9 @@ def test_relay_with_audio(): def test_source_command_captures_default_output(): cmd = L.source_command() assert "pactl get-default-sink" in cmd and "$sink.monitor" in cmd and "-c:a aac" in cmd - assert "-ts mono2abs" in cmd and "-use_wallclock_as_timestamps" not in cmd # both on pulse's wall clock + audio = cmd[cmd.index("audio=(-thread_queue_size"):cmd.index("fi\n", cmd.index("audio=(-thread_queue_size"))] + assert "-ts mono2abs" in cmd and "-use_wallclock_as_timestamps" not in audio # both on pulse's wall clock + # (the hardware path stamps its raw H.264 input with the wall clock; the pulse input never gets that flag) assert "aresample=async=1" in cmd @@ -291,7 +293,9 @@ def test_source_command_hardware_path(tmp_path): assert p.wait(timeout=5) == 0 assert b"live: encoder=hardware fps=32" in p.stderr.read() args = (tmp_path / "ffmpeg.args").read_text() - assert "-f h264 -framerate 32 -i pipe:0" in args and "-c:v copy" in args and "libx264" not in args + assert "-f h264 -i pipe:0" in args and "-c:v copy" in args and "libx264" not in args + assert "-use_wallclock_as_timestamps 1" in args and "nobuffer" not in args # same clock as pulse's audio + assert "+frag_keyframe" in args # a requested keyframe starts a fragment (where a new viewer begins) venc_calls = (tmp_path / "venc.args").read_text().splitlines() assert venc_calls[0].startswith("--probe ") and "--height 720" in venc_calls[0] and "--bitrate 3000000" in \ venc_calls[0]