Skip to content

In-plugin H.265 → H.264 transcoding (opt-in) + thermal governor#29

Open
josha wants to merge 3 commits into
caplaz:mainfrom
josha:upstream-transcode-thermal
Open

In-plugin H.265 → H.264 transcoding (opt-in) + thermal governor#29
josha wants to merge 3 commits into
caplaz:mainfrom
josha:upstream-transcode-thermal

Conversation

@josha

@josha josha commented May 31, 2026

Copy link
Copy Markdown

Stacked on #28. This branch contains #28 plus two additional feature commits. Please review/merge #28 first; the net-new changes here are the last two commits (feat: in-plugin H.265 -> H.264 transcoding and feat: thermal governor). Once #28 lands I'll rebase so this diff is just the feature.

An opt-in feature, separated from the bug fixes in #28 because it's a product opinion (it trades per-consumer adaptation and host CPU for plug-and-play H.264).

Why

Scrypted's FFmpegInput can't be told to produce H.264 — consumers (HomeKit, the Scrypted-UI WebRTC preview) do -c:v copy unless their own per-camera "Transcoding Debug Mode" is enabled. For H.265-only cameras (e.g. the 4G LTE S330s, and S340s when they auto-negotiate H.265) that means a black browser preview and finicky HomeKit setup. To make H.264 work out of the box, the plugin emits real H.264 itself.

What's here

  • utils/h264-transcode-server: a per-device local TCP relay. Each consumer connection spawns one ffmpeg that reads the muxed fMP4 (H.265) and pipes fragmented-MP4 H.264 (libx264 ultrafast/zerolatency, audio copied through) to it. The relay's ffmpeg becoming a muxed-port reader preserves the existing wake/idle-stop lifecycle.
  • Per-camera "Transcode to H.264" setting (default on for H.265 cameras). Only engages while the live source is actually H.265 — native H.264 passes through untouched.
  • utils/thermal-governor: the encode is a software libx264 per active stream, so on a small host (Raspberry Pi) it samples CPU temperature and auto-throttles new transcodes (falls back to H.265 passthrough) above a critical threshold, with hysteresis and a Scrypted UI alert. Inert when the temperature source is unreadable — it can only ever make the transcode path more conservative, never break it.

Trade-offs (documented, intentional)

  • One shared encode per stream, not per-consumer — loses Scrypted's per-consumer bitrate/resolution adaptation. Fine for a LAN-first setup.
  • Host CPU cost; mitigated by the per-camera toggle and the thermal governor. Hardware encode (h264_v4l2m2m) is a possible later optimization, left out of v1 for portability.

After enabling, users should turn OFF Scrypted's per-camera "Transcoding Debug Mode" (the stream is already H.264).

Testing

Unit-tested: relay lifecycle/spawn/teardown, the stream-service transcode branch, and the thermal governor (thresholds, hysteresis, unreadable-source inertness).

🤖 Generated with Claude Code

Josh Anon and others added 3 commits May 31, 2026 15:07
A Eufy HomeBase serves only ONE camera P2P stream at a time; opening the Home
app grid (a live request per tile) plus a tap fires several startLivestreams at
once, they mutually starve, and each camera's wedge logic independently recycles
the shared station — a cascading "wedge". This is a set of focused fixes for
that class of problem on HomeBase-mediated battery cameras.

- Per-HomeBase single-stream coordinator (utils/station-stream-coordinator):
  serialize P2P starts per station (concurrency 1); live preempts, background
  thumbnail refresh is denied while busy; clean newest-tap-wins handoff that
  stops the old camera's P2P before the new one starts (no overlap/starve).
  4G LTE cameras are their own station.
- Snapshot/thumbnail cache that never wakes the camera: serve the last keyframe
  as the tile image (any age) and return a neutral placeholder on a true miss
  instead of throwing (throwing made the Snapshot plugin fall back to the waking
  video path). Persisted across reloads. Optional per-camera background refresh.
- Cold-start: recycle a wedged/idle station P2P within the viewer's ~30s
  patience window (no-data threshold 45s -> 18s) instead of after it has quit.
- Audio-aware fMP4 muxing: pick JMuxer both vs video mode by whether the camera
  delivers usable (ADTS) audio, so mic-off / config-packet-only cameras don't
  hang the muxer (which left live view black); backstop rebuilds a stalled
  `both` muxer video-only after 4s.
- Report the true source codec to Scrypted so an H.265 stream isn't relabeled
  H.264 and `-vcodec copy`'d as-is.
- Recycle-suppression guard so a signal-dead camera can't keep recycling the
  shared HomeBase and disrupting its healthy siblings.

Unit tested across the stream server, coordinator, snapshot service, and
thumbnail refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…WebRTC

Scrypted's FFmpegInput can't request H.264 output — consumers (HomeKit, the
Scrypted-UI WebRTC preview) do `-c:v copy` unless the user manually enables a
per-camera "Transcoding Debug Mode". To make H.264 work out of the box (and fix
the black H.265-over-WebRTC live view), the plugin now emits real H.264 itself.

- h264-transcode-server.ts: a per-device local TCP relay. Each client
  connection spawns one ffmpeg that reads the stream server's muxed fMP4
  (H.265) port and pipes fragmented-MP4 H.264 (libx264 ultrafast/zerolatency,
  audio copied through) to that socket. One ffmpeg per client; stop() destroys
  live sockets so the listener closes promptly. The relay's ffmpeg becoming a
  muxed-port reader is what wakes/idle-stops the livestream, so the cold-start
  and coordinator lifecycle is preserved.
- stream-service.ts: when the toggle is on AND the source is H.265, return a
  MediaObject that reads the relay (advertised as h264); native H.264 passes
  through untouched, so a camera that sometimes sends H.264 is never needlessly
  re-encoded. getVideoStreamOptions advertises h264 to match.
- eufy-device.ts: per-camera "Transcode to H.264" setting (Streaming group),
  default ON for cameras whose last-detected codec is H.265.

Trade-off: one shared software encode per active stream (no per-consumer
bitrate adaptation), CPU cost on the host; the coordinator caps concurrency to
~one live stream per HomeBase. Toggle off per-camera if the host runs hot.

Tests: relay lifecycle/spawn/teardown + stream-service transcode branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…is hot

The in-plugin H.264 transcode is a software encode per active stream, which can
heat a small host (Raspberry Pi) under concurrent streams. Add a process-wide
thermal governor that samples the host CPU temperature
(/sys/class/thermal/thermal_zone0/temp) every 10s and:

- warns (log + Scrypted alert via log.a) at >=70C,
- throttles at >=78C: new streams that would transcode fall back to H.265
  passthrough (no encode) until the host cools,
- uses hysteresis (clear critical <72C, clear warn <66C) so it doesn't flap,
- is inert when the temperature source is unreadable (non-Pi / sandbox) — it can
  only ever make the transcode path more conservative, never break it.

Existing encodes are left to finish (short live-view sessions) rather than
killed mid-frame. StreamService gains an isThrottling getter; the provider
starts the governor and routes alerts to the Scrypted UI.

Tests: thresholds, hysteresis, unreadable-source inertness, alert transitions,
and the singleton throttle flag.

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.

1 participant