Skip to content

Tune for dedicated GPUs and add an OBS output mode - #2

Open
Divuzki wants to merge 4 commits into
philipp-eisen:mainfrom
Divuzki:claude/obs-compute-optimization-dehbvx
Open

Tune for dedicated GPUs and add an OBS output mode#2
Divuzki wants to merge 4 commits into
philipp-eisen:mainfrom
Divuzki:claude/obs-compute-optimization-dehbvx

Conversation

@Divuzki

@Divuzki Divuzki commented Aug 12, 2026

Copy link
Copy Markdown

Two things were in the way of running this as a stream source: the session was capped at two minutes, and the page is wrapped in UI that any capture would pick up. The per-frame pipeline also did a lot of work nothing read.

Compute:

  • Make the hardware and pipeline configurable through FACESTREAM_* env vars (facestream/config.py). Runtime values are forwarded into the container via the class's env= so the setting used at deploy time is the one that applies.
  • Default to L40S rather than T4. This pipeline runs two small models per frame and is bound by kernel launch latency, so cards above that tier add cost without adding frames.
  • Only run the detector per frame. FaceAnalysis.get() also runs two landmark models, gender/age and ArcFace recognition on every detected face; the swapper reads none of it, it needs the detector's keypoints and the source face's embedding.
  • Search a crop around the last known face at a smaller detector input size. The detector rescales its input to a fixed square, so a smaller square is what makes it cheaper, and on a crop the face still resolves at higher effective detail than a full-frame pass.
  • Composite the swapped face inside its bounding box instead of over the whole frame, and drop upstream's fake_diff mask, which costs a full-frame warp, threshold, dilate and blur before being discarded (img_mask = img_white overwrites it). Verified against the upstream implementation across face sizes, rotations and frames clipped at the edges: identical kernel sizes, differences within sub-pixel resampling rounding.
  • Warm up cuDNN at container start, ask onnxruntime for heuristic convolution selection instead of exhaustive search, and serialise inference on one worker thread.
  • Raise the session cap to an hour, guarded by a websocket idle timeout so a tab that dies without closing its socket can't hold a GPU open.
  • Keep the newest frame only, and restamp a repeated frame with the timestamp of the frame it stands in for -- reusing the old one makes the receiver treat the packets as a duplicate.
  • Track detection state per stream rather than per container, so concurrent sessions don't steer each other's search window.

OBS:

  • ?obs=1 renders the swapped video full-bleed with no chrome, takes its face from ?face=, and reconnects on a backoff instead of raising dialogs nothing in OBS can answer. Normal mode still asks before reconnecting.
  • Hint the encoder's start bitrate in the answer SDP. Chrome otherwise opens at roughly 300 kbps and creeps up, so a stream spent its first ~20 seconds at 640x360; it now sends 720p from the first second.
  • Add options for resolution, frame rate, bitrate, mirroring, fit, camera selection, degradation preference and a live stats overlay.
  • Report a missing face on the source image instead of failing silently, and surface errors in the page rather than in blocking alerts.
  • Follow the page protocol when building the websocket URL, so a local or self-hosted deployment over http works.

README covers GPU selection, every env var, and both OBS capture routes.

Claude-Session: https://claude.ai/code/session_01V9EYcqE7xVeRrDnZorEoFA

Summary by CodeRabbit

  • New Features

    • Added configurable camera, resolution, FPS, bitrate, mirroring, fitting, face selection, and server options.
    • Added OBS and clean display modes, virtual camera guidance, live statistics, and connection status overlays.
    • Added health checks, startup warmup, configurable processing and scaling settings, and optional source photos and webcams.
    • Added optional TURN connectivity with automatic fallback for WebRTC sessions.
  • Bug Fixes

    • Improved handling of invalid images, missing faces, connection failures, timeouts, and autoplay compatibility.
    • Improved video tracking, frame recovery, reconnection, and startup performance.
  • Documentation

    • Expanded deployment, configuration, consent, networking, and usage guidance.

Two things were in the way of running this as a stream source: the session
was capped at two minutes, and the page is wrapped in UI that any capture
would pick up. The per-frame pipeline also did a lot of work nothing read.

Compute:

- Make the hardware and pipeline configurable through FACESTREAM_* env vars
  (facestream/config.py). Runtime values are forwarded into the container via
  the class's env= so the setting used at deploy time is the one that applies.
- Default to L40S rather than T4. This pipeline runs two small models per
  frame and is bound by kernel launch latency, so cards above that tier add
  cost without adding frames.
- Only run the detector per frame. FaceAnalysis.get() also runs two landmark
  models, gender/age and ArcFace recognition on every detected face; the
  swapper reads none of it, it needs the detector's keypoints and the source
  face's embedding.
- Search a crop around the last known face at a smaller detector input size.
  The detector rescales its input to a fixed square, so a smaller square is
  what makes it cheaper, and on a crop the face still resolves at higher
  effective detail than a full-frame pass.
- Composite the swapped face inside its bounding box instead of over the whole
  frame, and drop upstream's fake_diff mask, which costs a full-frame warp,
  threshold, dilate and blur before being discarded (img_mask = img_white
  overwrites it). Verified against the upstream implementation across face
  sizes, rotations and frames clipped at the edges: identical kernel sizes,
  differences within sub-pixel resampling rounding.
- Warm up cuDNN at container start, ask onnxruntime for heuristic convolution
  selection instead of exhaustive search, and serialise inference on one
  worker thread.
- Raise the session cap to an hour, guarded by a websocket idle timeout so a
  tab that dies without closing its socket can't hold a GPU open.
- Keep the newest frame only, and restamp a repeated frame with the timestamp
  of the frame it stands in for -- reusing the old one makes the receiver
  treat the packets as a duplicate.
- Track detection state per stream rather than per container, so concurrent
  sessions don't steer each other's search window.

OBS:

- ?obs=1 renders the swapped video full-bleed with no chrome, takes its face
  from ?face=, and reconnects on a backoff instead of raising dialogs nothing
  in OBS can answer. Normal mode still asks before reconnecting.
- Hint the encoder's start bitrate in the answer SDP. Chrome otherwise opens
  at roughly 300 kbps and creeps up, so a stream spent its first ~20 seconds
  at 640x360; it now sends 720p from the first second.
- Add options for resolution, frame rate, bitrate, mirroring, fit, camera
  selection, degradation preference and a live stats overlay.
- Report a missing face on the source image instead of failing silently, and
  surface errors in the page rather than in blocking alerts.
- Follow the page protocol when building the websocket URL, so a local or
  self-hosted deployment over http works.

README covers GPU selection, every env var, and both OBS capture routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9EYcqE7xVeRrDnZorEoFA
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Divuzki, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f25bad0-bb57-47b8-a859-5548119c02ef

📥 Commits

Reviewing files that changed from the base of the PR and between 4564455 and 25d7a86.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • .github/workflows/ci.yml
  • .github/workflows/deploy.yml
  • README.md
  • pyproject.toml
  • tests/conftest.py
  • tests/stub_server.py
  • tests/test_browser.py
  • tests/test_paste_back.py
  • tests/test_track.py
  • tests/test_tracking.py
  • tests/test_turn.py
📝 Walkthrough

Walkthrough

The PR adds centralized configuration, optimized tracked face swapping, resilient WebSocket and frame processing, Cloudflare TURN fallback, a health endpoint, and configurable browser and OBS WebRTC workflows. The README documents deployment, runtime settings, troubleshooting, and capture options.

Changes

Face-swap runtime and client flow

Layer / File(s) Summary
Configuration and deployment wiring
src/facestream/config.py, src/facestream/main.py, README.md
Environment variables now configure deployment and runtime settings. Modal receives runtime settings, /healthz reports active configuration, and the README documents deployment options.
Tracked detection and compositing
src/facestream/faceswap.py
Target detection uses tracked regions, detector keypoints, largest-face selection, fallback detection, serialized execution, warmup, and optional fast compositing.
Stream lifecycle, frame resilience, and ICE fallback
src/facestream/main.py, src/facestream/track.py, src/facestream/turn.py
Streams use per-stream trackers and WebSocket idle timeouts. Frame processing records drops, repeats, timing, and errors. ICE setup retrieves Cloudflare TURN credentials and falls back to Google STUN. Invalid source images return client errors.
Browser, camera, and OBS controls
web/index.html, README.md
The client supports URL-configurable capture, bitrate, face selection, statistics, retries, camera selection, image formats, and OBS workflows.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant WebSocket
  participant Main
  participant ProcessFrameTrack
  participant FaceSwap
  Browser->>WebSocket: connect and submit source face
  WebSocket->>Main: send session and frame messages
  Main->>ProcessFrameTrack: submit video frames
  ProcessFrameTrack->>FaceSwap: process frame with FaceTracker
  FaceSwap-->>ProcessFrameTrack: return composited frame
  ProcessFrameTrack-->>Browser: deliver processed or repeated frame
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the two primary changes: dedicated GPU tuning and OBS output mode support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Cloudflare:

- Move ICE server selection into facestream/turn.py and switch to the
  documented generate-ice-servers endpoint.
- Fall back to STUN when Cloudflare is unreachable or the token is rejected.
  The previous code indexed the response directly, so any error body raised a
  KeyError out of the websocket handler and ended the session.
- Make the TURN secret conditional on FACESTREAM_TURN rather than something
  you uncomment in main.py. Naming a Modal secret that doesn't exist fails the
  deploy, which is why it couldn't just be enabled by default.
- Default the websocket to the page's own origin. Matching on "modal.run"
  meant anyone serving this from a custom domain -- the documented way to put
  Cloudflare in front -- silently connected to the upstream demo's backend
  instead of their own.

Streaming:

- Don't emit camera frames while waiting for the first swap. There is nothing
  to repeat at the start of a session, so the stream opened with a flash of
  the real face, which is long enough to land in a recording. Falls back to
  the camera frame if no swap arrives within a few seconds, so a broken
  pipeline can't wedge the connection.
- Stamp every outgoing frame with the timestamp of the frame it stands in for.
  A swapped frame carried the timestamp of the input it was computed from,
  which by then is behind timestamps already sent, so the output clock went
  backwards whenever a repeat had been emitted; receivers read those as
  duplicates rather than new frames.
- Drop the tracked bounding box when the frame size changes. Browsers rescale
  mid-stream as their bandwidth estimate moves, leaving the remembered box in
  the previous resolution's coordinates.
- Default to one stream per container. A page load and a websocket fit
  together at 2, and the next viewer's websocket starts a new container, so
  streams stop sharing a GPU by default.

README covers the TURN setup, custom domains, splitting Pages from Modal, and
a production checklist -- including that the underlying model is licensed for
non-commercial use only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9EYcqE7xVeRrDnZorEoFA

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (3)
src/facestream/config.py (1)

162-177: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Report every active tuning value.

src/facestream/main.py:74 logs this dictionary at startup. The report omits BUFFER_CONTAINERS, MAX_CONTAINERS, TRACK_ROI_SCALE, DET_THRESH, STATS_INTERVAL, and INPUT_QUEUE_SIZE. Add these values so the startup log can identify the active deployment and runtime behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/config.py` around lines 162 - 177, Update the config.describe
function to include BUFFER_CONTAINERS, MAX_CONTAINERS, TRACK_ROI_SCALE,
DET_THRESH, STATS_INTERVAL, and INPUT_QUEUE_SIZE in its returned dictionary,
preserving the existing entries so the startup log reports every active tuning
value.
src/facestream/track.py (1)

110-115: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Rate limit the per-frame exception logging.

A persistent failure, such as a CUDA out-of-memory condition, raises on every frame. At 30 fps each stream then writes about 30 stack traces per second, which buries the first cause and inflates log cost. Log the full trace once, then count repeats.

♻️ Proposed change
             except asyncio.CancelledError:
                 raise
             except Exception:
-                logger.exception("Error processing frame")
+                self._error_count += 1
+                if self._error_count == 1:
+                    logger.exception("Error processing frame")
+                elif self._error_count % 100 == 0:
+                    logger.error(
+                        "Frame processing still failing (%d consecutive errors)",
+                        self._error_count,
+                    )

Initialize self._error_count = 0 in __init__ and reset it in _record_stats.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/track.py` around lines 110 - 115, Rate-limit exception
handling in the frame-processing loop by tracking repeated failures with an
instance counter initialized in __init__. In the generic Exception branch, log
the full traceback only for the first occurrence and count subsequent exceptions
without emitting another stack trace; reset the counter in _record_stats for
each reporting period.
src/facestream/faceswap.py (1)

140-155: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the CUDA-only provider list.

When CUDA is unavailable, return ["CPUExecutionProvider"]. Otherwise, keep the CUDA options and append the CPU provider as a fallback. The option names are valid for ONNX Runtime 1.16.3.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/faceswap.py` around lines 140 - 155, Update _providers to
return ["CPUExecutionProvider"] when CUDA is unavailable; when CUDA is
available, retain the existing CUDA provider options and append
CPUExecutionProvider as a fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/facestream/config.py`:
- Around line 50-54: Update _env_bool to accept recognized true values and
explicit false values, returning default only when the environment variable is
unset or empty. Raise ValueError for any other non-empty value so misspellings
cannot silently disable features.
- Around line 86-90: Update the MAX_CONCURRENT_INPUTS configuration loading to
validate the parsed environment value and reject any value below 2 during
deployment configuration initialization, while preserving the existing default
of 4 and accepting values of 2 or greater.

In `@src/facestream/main.py`:
- Around line 154-157: Update the healthz endpoint to return only the liveness
status and remove the unauthenticated config.describe() response; alternatively,
protect configuration exposure behind an authentication check while preserving
the health probe’s unauthenticated status response.
- Around line 184-188: Update the JSONDecodeError handler around json.loads in
the websocket message processing flow to stop logging raw payloads; log the
payload size and only a short prefix instead, ensuring malformed upload_image
messages cannot emit the full base64 photograph or multi-megabyte log lines.
- Around line 199-208: Validate each uploaded image before passing it to
FaceAnalysis.get, handling invalid base64, missing image data, and cv2.imdecode
returning None by sending the existing error response and continuing the
websocket session. Update the upload flow around source_face so failed uploads
do not overwrite an already valid source face, and keep the decoded image
separate from the established Face value used by later offer processing.

In `@web/index.html`:
- Around line 657-664: Remove the server query-parameter override from
backendUrl so the WebSocket endpoint cannot be controlled by an untrusted URL
parameter. Use only the trusted configured FaceStream origin, or validate server
against an explicit trusted-host allowlist before constructing the endpoint.
- Around line 566-584: Validate the numeric URL parameters before assigning
MAX_RECONNECTS, CAPTURE_FPS, and MAX_KBPS: require finite integers, reject
malformed or negative values, and clamp each to its supported range. Ensure the
resulting constants are always valid for reconnect comparisons, getUserMedia
constraints, and SDP bitrate generation, while preserving the existing defaults
for missing parameters.
- Around line 1067-1078: Update the no-match branch in the camera-selection flow
to throw immediately after showError when no camera matches the requested
camera. Ensure navigator.mediaDevices.getUserMedia is reached only when a
matching deviceId has been assigned, while preserving the existing error
message.
- Around line 657-672: Update backendUrl() so the no-override fallback always
builds the WebSocket URL from window.location.protocol and window.location.host,
removing the modal.run hostname check and hardcoded deployment URL. Preserve the
explicit server query parameter override behavior.
- Around line 641-654: Update applyMode() to return a failure state when the OBS
face is missing or unknown, after showing the existing error. Change the startup
flow to call start() only when applyMode() reports successful validation,
preventing the heartbeat WebSocket from opening for invalid OBS configuration.

---

Nitpick comments:
In `@src/facestream/config.py`:
- Around line 162-177: Update the config.describe function to include
BUFFER_CONTAINERS, MAX_CONTAINERS, TRACK_ROI_SCALE, DET_THRESH, STATS_INTERVAL,
and INPUT_QUEUE_SIZE in its returned dictionary, preserving the existing entries
so the startup log reports every active tuning value.

In `@src/facestream/faceswap.py`:
- Around line 140-155: Update _providers to return ["CPUExecutionProvider"] when
CUDA is unavailable; when CUDA is available, retain the existing CUDA provider
options and append CPUExecutionProvider as a fallback.

In `@src/facestream/track.py`:
- Around line 110-115: Rate-limit exception handling in the frame-processing
loop by tracking repeated failures with an instance counter initialized in
__init__. In the generic Exception branch, log the full traceback only for the
first occurrence and count subsequent exceptions without emitting another stack
trace; reset the counter in _record_stats for each reporting period.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e91593fe-deca-4420-b54d-524e02858c07

📥 Commits

Reviewing files that changed from the base of the PR and between 1af9150 and 11e19b0.

📒 Files selected for processing (6)
  • README.md
  • src/facestream/config.py
  • src/facestream/faceswap.py
  • src/facestream/main.py
  • src/facestream/track.py
  • web/index.html

Comment thread src/facestream/config.py
Comment on lines +50 to +54
def _env_bool(name: str, default: bool) -> bool:
value = os.environ.get(name)
if not value:
return default
return value.strip().lower() in ("1", "true", "yes", "on")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject invalid Boolean values.

_env_bool converts every unrecognized non-empty value to False. A typo such as FACESTREAM_FACE_TRACKING=ture silently disables the feature. Accept explicit false values, then raise ValueError for other values.

Proposed fix
 def _env_bool(name: str, default: bool) -> bool:
     value = os.environ.get(name)
     if not value:
         return default
-    return value.strip().lower() in ("1", "true", "yes", "on")
+    normalized = value.strip().lower()
+    if normalized in ("1", "true", "yes", "on"):
+        return True
+    if normalized in ("0", "false", "no", "off"):
+        return False
+    raise ValueError(f"{name} must be a Boolean value")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _env_bool(name: str, default: bool) -> bool:
value = os.environ.get(name)
if not value:
return default
return value.strip().lower() in ("1", "true", "yes", "on")
def _env_bool(name: str, default: bool) -> bool:
value = os.environ.get(name)
if not value:
return default
normalized = value.strip().lower()
if normalized in ("1", "true", "yes", "on"):
return True
if normalized in ("0", "false", "no", "off"):
return False
raise ValueError(f"{name} must be a Boolean value")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/config.py` around lines 50 - 54, Update _env_bool to accept
recognized true values and explicit false values, returning default only when
the environment variable is unset or empty. Raise ValueError for any other
non-empty value so misspellings cannot silently disable features.

Comment thread src/facestream/config.py Outdated
Comment on lines +86 to +90
# Concurrent inputs per container. This must stay above 1: a live websocket
# occupies an input for the whole session, so a container with max_inputs=1
# could not even serve index.html while someone is streaming. Note that each
# extra concurrent stream shares the same GPU.
MAX_CONCURRENT_INPUTS = _env_int("FACESTREAM_MAX_CONCURRENT_INPUTS", 4)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Enforce the minimum concurrent-input value.

Line 90 accepts 0 and 1, although the configuration requires more than one concurrent input. A live WebSocket can consume the only input slot, and 0 cannot serve requests. Reject values below 2 during deployment configuration loading.

Proposed fix
 MAX_CONCURRENT_INPUTS = _env_int("FACESTREAM_MAX_CONCURRENT_INPUTS", 4)
+if MAX_CONCURRENT_INPUTS < 2:
+    raise ValueError("FACESTREAM_MAX_CONCURRENT_INPUTS must be at least 2")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Concurrent inputs per container. This must stay above 1: a live websocket
# occupies an input for the whole session, so a container with max_inputs=1
# could not even serve index.html while someone is streaming. Note that each
# extra concurrent stream shares the same GPU.
MAX_CONCURRENT_INPUTS = _env_int("FACESTREAM_MAX_CONCURRENT_INPUTS", 4)
# Concurrent inputs per container. This must stay above 1: a live websocket
# occupies an input for the whole session, so a container with max_inputs=1
# could not even serve index.html while someone is streaming. Note that each
# extra concurrent stream shares the same GPU.
MAX_CONCURRENT_INPUTS = _env_int("FACESTREAM_MAX_CONCURRENT_INPUTS", 4)
if MAX_CONCURRENT_INPUTS < 2:
raise ValueError("FACESTREAM_MAX_CONCURRENT_INPUTS must be at least 2")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/config.py` around lines 86 - 90, Update the
MAX_CONCURRENT_INPUTS configuration loading to validate the parsed environment
value and reject any value below 2 during deployment configuration
initialization, while preserving the existing default of 4 and accepting values
of 2 or greater.

Comment thread src/facestream/main.py
Comment on lines +154 to +157
@web_app.get("/healthz")
def healthz():
return {"status": "ok", "config": config.describe()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Do not publish deployment configuration anonymously.

/healthz is unauthenticated and returns config.describe(), which includes GPU type, CPU, region, container limits, and timeouts. A liveness probe needs only the status. Return the configuration behind a check, or drop it.

🔒 Proposed change
         `@web_app.get`("/healthz")
         def healthz():
-            return {"status": "ok", "config": config.describe()}
+            return {"status": "ok"}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@web_app.get("/healthz")
def healthz():
return {"status": "ok", "config": config.describe()}
@web_app.get("/healthz")
def healthz():
return {"status": "ok"}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/main.py` around lines 154 - 157, Update the healthz endpoint
to return only the liveness status and remove the unauthenticated
config.describe() response; alternatively, protect configuration exposure behind
an authentication check while preserving the health probe’s unauthenticated
status response.

Comment thread src/facestream/main.py
Comment on lines +184 to 188
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.error("Received invalid JSON: %s", data)
logger.error("Received invalid JSON: %s", raw)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not log the raw websocket payload.

The upload_image message contains a base64 encoded photograph of the user. If such a message is truncated or malformed, line 187 writes that biometric data into the logs and emits a multi-megabyte log line. Log the size and a short prefix instead.

🔒 Proposed fix
                     try:
                         data = json.loads(raw)
                     except json.JSONDecodeError:
-                        logger.error("Received invalid JSON: %s", raw)
+                        logger.error(
+                            "Received invalid JSON (%d bytes, starts with %r)",
+                            len(raw),
+                            raw[:64],
+                        )
                         continue
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.error("Received invalid JSON: %s", data)
logger.error("Received invalid JSON: %s", raw)
continue
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.error(
"Received invalid JSON (%d bytes, starts with %r)",
len(raw),
raw[:64],
)
continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/main.py` around lines 184 - 188, Update the JSONDecodeError
handler around json.loads in the websocket message processing flow to stop
logging raw payloads; log the payload size and only a short prefix instead,
ensuring malformed upload_image messages cannot emit the full base64 photograph
or multi-megabyte log lines.

Comment thread src/facestream/main.py
Comment on lines +199 to +208
if source_face is None:
logger.info("No face found in the uploaded image")
await websocket.send_json(
{
"type": "error",
"message": "No face found in that image. Try another one.",
}
)
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate the uploaded image before analysis.

The new branch covers "no face detected" but not undecodable input. cv2.imdecode returns None for data that is not an image, and FaceAnalysis.get(None) then raises inside the executor. The generic handler closes the websocket and re-raises, so one malformed upload ends the session. base64.b64decode and data["image"] fail the same way.

Also, source_face holds both the decoded image and the resulting Face. A failed second upload therefore clears an already established source face, and a later offer raises "Invalid state".

🛡️ Proposed fix
                     if data.get("type") == "upload_image":
                         logger.info("Received image. Processing...")
-                        image = data["image"]
-
-                        image_bytes = base64.b64decode(image)
-                        nparr = np.frombuffer(image_bytes, np.uint8)
-                        source_face = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
-                        source_face = await self.faceswap.get_one_face(source_face)
-
-                        if source_face is None:
+                        image = data.get("image")
+                        decoded = None
+                        if image:
+                            try:
+                                nparr = np.frombuffer(
+                                    base64.b64decode(image), np.uint8
+                                )
+                                decoded = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
+                            except (ValueError, TypeError):
+                                decoded = None
+                        if decoded is None:
+                            logger.info("Could not decode the uploaded image")
+                            await websocket.send_json(
+                                {
+                                    "type": "error",
+                                    "message": "That file is not a readable image. Try another one.",
+                                }
+                            )
+                            continue
+
+                        detected = await self.faceswap.get_one_face(decoded)
+                        if detected is None:
                             logger.info("No face found in the uploaded image")
                             await websocket.send_json(
                                 {
                                     "type": "error",
                                     "message": "No face found in that image. Try another one.",
                                 }
                             )
                             continue
+                        source_face = detected
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/main.py` around lines 199 - 208, Validate each uploaded image
before passing it to FaceAnalysis.get, handling invalid base64, missing image
data, and cv2.imdecode returning None by sending the existing error response and
continuing the websocket session. Update the upload flow around source_face so
failed uploads do not overwrite an already valid source face, and keep the
decoded image separate from the established Face value used by later offer
processing.

Comment thread web/index.html
Comment on lines +566 to +584
const MAX_RECONNECTS = parseInt(params.get("retries") || "20", 10);
const HEARTBEAT_MS = 15000;

const RESOLUTIONS = {
480: [640, 480],
540: [960, 540],
720: [1280, 720],
1080: [1920, 1080],
};
const [CAPTURE_WIDTH, CAPTURE_HEIGHT] =
RESOLUTIONS[params.get("res")] || RESOLUTIONS[720];
const CAPTURE_FPS = parseInt(params.get("fps") || "30", 10);

// Left alone, Chrome opens at roughly 300 kbps and creeps upwards, so the
// first half-minute of a stream goes out at 640x360 even on a fast link.
// These hints tell it to start where we expect to end up.
const MAX_KBPS = parseInt(params.get("bitrate") || "3000", 10);
const START_KBPS = Math.min(2500, MAX_KBPS);
const MIN_KBPS = Math.min(800, MAX_KBPS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate numeric URL options before use.

parseInt() can return NaN or accept negative values. An invalid bitrate writes invalid SDP values such as b=AS:NaN. An invalid fps can make getUserMedia() reject its constraints. An invalid retries disables the reconnect limit because comparisons with NaN are false.

Parse each value as a finite integer and clamp it to a supported range before assigning these constants.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/index.html` around lines 566 - 584, Validate the numeric URL parameters
before assigning MAX_RECONNECTS, CAPTURE_FPS, and MAX_KBPS: require finite
integers, reject malformed or negative values, and clamp each to its supported
range. Ensure the resulting constants are always valid for reconnect
comparisons, getUserMedia constraints, and SDP bitrate generation, while
preserving the existing defaults for missing parameters.

Comment thread web/index.html
Comment on lines +641 to +654
if (requestedFace && !autoFaceUrl) {
showError(
`Unknown face "${requestedFace}". Use an image URL or one of: ` +
Object.keys(PRESETS).join(", "),
true
);
} else if (!requestedFace) {
showError(
"OBS mode needs a face: add &face=" +
Object.keys(PRESETS)[0] +
" (or &face=<image url>) to the URL.",
true
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 '\bapplyMode\s*\(|\bstart\s*\(|DOMContentLoaded|window\.onload|addEventListener\(\s*["'\'']load' web/index.html

Repository: philipp-eisen/facestream

Length of output: 1779


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '620,675p' web/index.html
sed -n '960,1015p' web/index.html
sed -n '1170,1192p' web/index.html

Repository: philipp-eisen/facestream

Length of output: 4860


Prevent OBS startup when face is missing or unknown.

applyMode() shows the error but does not stop execution. The unconditional start() call still opens a heartbeat WebSocket without a stream. Return a failure state from applyMode() and call start() only when validation succeeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/index.html` around lines 641 - 654, Update applyMode() to return a
failure state when the OBS face is missing or unknown, after showing the
existing error. Change the startup flow to call start() only when applyMode()
reports successful validation, preventing the heartbeat WebSocket from opening
for invalid OBS configuration.

Comment thread web/index.html
Comment thread web/index.html
Comment thread web/index.html
Comment on lines +1067 to +1078
if (match) {
constraints.video.deviceId = { exact: match.deviceId };
} else {
showError(
`No camera matching "${wanted}". Available: ` +
(cameras.map((camera) => camera.label).join(", ") || "none"),
true
);
}
}

return navigator.mediaDevices.getUserMedia(constraints);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Stop when the requested camera is not available.

If no camera matches camera, this code shows an error but still calls getUserMedia() without deviceId. The browser can then capture the default camera instead of the requested camera.

Throw after showError() when no match exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/index.html` around lines 1067 - 1078, Update the no-match branch in the
camera-selection flow to throw immediately after showError when no camera
matches the requested camera. Ensure navigator.mediaDevices.getUserMedia is
reached only when a matching deviceId has been assigned, while preserving the
existing error message.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Line 207: Update the fenced code blocks in README.md at the specified
command-block locations to use the shell language identifier, and assign
appropriate non-command identifiers to the URL-only blocks. Also correct the
documentation describing FACESTREAM_MAX_CONCURRENT_INPUTS=2 so it states that
two simultaneous inputs are permitted by default rather than guaranteeing one
stream per container.

In `@src/facestream/track.py`:
- Around line 81-102: Update _first_frame and the related recv flow to track a
passthrough state when FIRST_FRAME_TIMEOUT expires, so subsequent incoming
frames return immediately without waiting again. Clear that state when a
processed frame is received, and ensure the active passthrough path returns each
incoming camera frame while processing remains unavailable.

In `@src/facestream/turn.py`:
- Around line 22-25: Update the facestream timeout handling associated with
FACESTREAM_TIMEOUT and FACESTREAM_TURN so sessions cannot outlive the
86,400-second CREDENTIAL_TTL_SECONDS, either by enforcing that maximum timeout
or by refreshing TURN credentials before expiration; preserve existing behavior
for shorter sessions.
- Line 11: Declare aiohttp as a direct runtime dependency in the project’s
runtime dependency configuration so facestream.turn can import it reliably when
facestream.main loads. Keep the existing STUN fallback behavior unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ee36d139-7d95-40a9-b5dc-156345e42b49

📥 Commits

Reviewing files that changed from the base of the PR and between 11e19b0 and 4564455.

📒 Files selected for processing (7)
  • README.md
  • src/facestream/config.py
  • src/facestream/faceswap.py
  • src/facestream/main.py
  • src/facestream/track.py
  • src/facestream/turn.py
  • web/index.html
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/facestream/faceswap.py
  • web/index.html
  • src/facestream/main.py

Comment thread README.md

2. Put them in a Modal secret named `facestream`:

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the deployment decorator and configuration mapping without running code.
ast-grep outline src/facestream/main.py --items all
ast-grep outline src/facestream/config.py --items all
rg -n -C 5 'MAX_CONCURRENT_INPUTS|max_inputs|concurrent_inputs|FACESTREAM_MAX_CONCURRENT_INPUTS' \
  src/facestream/main.py src/facestream/config.py README.md

Repository: philipp-eisen/facestream

Length of output: 7653


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- README.md relevant sections ---'
sed -n '88,110p;198,220p;238,250p;252,268p;276,286p' README.md

printf '%s\n' '--- fenced code blocks without language identifiers ---'
python3 - <<'PY'
from pathlib import Path
import re

text = Path("README.md").read_text()
lines = text.splitlines()
fence_re = re.compile(r"^(\s*)(`{3,}|~{3,})(.*)$")
open_fence = None

for number, line in enumerate(lines, 1):
    match = fence_re.match(line)
    if not match:
        continue
    marker = match.group(2)
    info = match.group(3).strip()
    if open_fence is None:
        if not info:
            open_fence = (number, marker[0], len(marker))
            print(f"OPEN line {number}: language=<missing>")
        else:
            open_fence = (number, marker[0], len(marker))
            print(f"OPEN line {number}: language={info}")
    elif marker[0] == open_fence[1] and len(marker) >= open_fence[2]:
        print(f"CLOSE line {number} for block opened at {open_fence[0]}")
        open_fence = None

if open_fence:
    print(f"UNCLOSED block opened at line {open_fence[0]}")
PY

printf '%s\n' '--- deployment and web entry points ---'
sed -n '28,90p;150,230p' src/facestream/main.py

Repository: philipp-eisen/facestream

Length of output: 11296


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- all unlabeled README fences and their contents ---'
python3 - <<'PY'
from pathlib import Path
import re

lines = Path("README.md").read_text().splitlines()
fence = re.compile(r"^\s*(`{3,}|~{3,})(.*)$")
open_block = None

for number, line in enumerate(lines, 1):
    match = fence.match(line)
    if not match:
        if open_block is not None:
            open_block["body"].append((number, line))
        continue

    marker, info = match.groups()
    if open_block is None:
        open_block = {
            "start": number,
            "char": marker[0],
            "length": len(marker),
            "info": info.strip(),
            "body": [],
        }
    elif marker[0] == open_block["char"] and len(marker) >= open_block["length"]:
        if not open_block["info"]:
            print(f"\nBLOCK {open_block['start']}-{number}")
            for body_number, body_line in open_block["body"]:
                print(f"{body_number:4}: {body_line}")
        open_block = None
PY

printf '%s\n' '--- source configuration and deployment semantics ---'
sed -n '80,100p' src/facestream/config.py
sed -n '70,90p' src/facestream/main.py
rg -n -C 4 'one stream|per container|MAX_CONCURRENT_INPUTS|max_inputs|concurr' \
  README.md src web

Repository: philipp-eisen/facestream

Length of output: 8747


Add language identifiers to all fenced code blocks.

Use shell for command blocks at README.md lines 30, 44, 50, 59, 207, 215, and 282. Use an appropriate non-command identifier for URL-only blocks at lines 130 and 246.

The default FACESTREAM_MAX_CONCURRENT_INPUTS=2 permits two simultaneous inputs per container. Do not document it as guaranteeing one stream per container unless the deployment requires that behavior.

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 207-207: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 207, Update the fenced code blocks in README.md at the
specified command-block locations to use the shell language identifier, and
assign appropriate non-command identifiers to the URL-only blocks. Also correct
the documentation describing FACESTREAM_MAX_CONCURRENT_INPUTS=2 so it states
that two simultaneous inputs are permitted by default rather than guaranteeing
one stream per container.

Source: Linters/SAST tools

Comment thread src/facestream/track.py
Comment on lines +81 to +102
async def _first_frame(self, original_frame):
"""Wait for the first swapped frame instead of sending a camera frame.

There is nothing to repeat at the start of a session, and passing the
camera frame through would put the real face on screen for the first
frames of every stream -- long enough to be caught on a recording.
"""
try:
_, processed_frame = await asyncio.wait_for(
self.output_queue.get(), timeout=FIRST_FRAME_TIMEOUT
)
except asyncio.TimeoutError:
# Something is badly wrong upstream. A late unswapped frame beats a
# stalled connection, and the swap takes over as soon as it works.
logger.warning(
"No swapped frame within %ss, passing the camera through",
FIRST_FRAME_TIMEOUT,
)
return original_frame

self.last_frame = processed_frame
return processed_frame

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep passthrough active after the first-frame timeout.

When processing remains unavailable, Line 92 times out and Line 99 returns one camera frame without updating state. The next recv() still has last_frame is None, so it waits another three seconds. The fallback then produces one frame per timeout interval instead of a usable passthrough stream.

Set a passthrough state on timeout. Return incoming frames while that state is active. Clear the state when a processed frame arrives.

Proposed fix
         self.processing_task = asyncio.create_task(self._processor())
         self.last_frame = None
+        self._passthrough = False
         self.frame_counter = 0

         try:
             _, processed_frame = self.output_queue.get_nowait()
             self.last_frame = processed_frame
+            self._passthrough = False
         except asyncio.QueueEmpty:
             if self.last_frame is None:
+                if self._passthrough:
+                    return original_frame
                 return await self._first_frame(original_frame)

         except asyncio.TimeoutError:
+            self._passthrough = True
             logger.warning(
                 "No swapped frame within %ss, passing the camera through",
                 FIRST_FRAME_TIMEOUT,
             )
             return original_frame

         self.last_frame = processed_frame
+        self._passthrough = False
         return processed_frame
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/track.py` around lines 81 - 102, Update _first_frame and the
related recv flow to track a passthrough state when FIRST_FRAME_TIMEOUT expires,
so subsequent incoming frames return immediately without waiting again. Clear
that state when a processed frame is received, and ensure the active passthrough
path returns each incoming camera frame while processing remains unavailable.

Comment thread src/facestream/turn.py

import logging

import aiohttp

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect declared Python dependencies without executing repository code.
fd -a -t f '^(pyproject\.toml|requirements.*\.txt|setup\.py|Pipfile)$' . \
  -x rg -n -C 2 '\baiohttp\b|^\s*(dependencies|install_requires)\s*='

Repository: philipp-eisen/facestream

Length of output: 281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- dependency manifests ---'
fd -a -t f '^(pyproject\.toml|requirements.*\.txt|setup\.py|Pipfile)$' . \
  -x sh -c 'echo "--- $1"; cat -n "$1"' sh

printf '%s\n' '--- relevant source files ---'
for name in turn.py main.py config.py; do
  fd -a -t f "$name" . -x sh -c 'echo "--- $1"; cat -n "$1"' sh
done

printf '%s\n' '--- references and documentation ---'
rg -n -C 3 'aiohttp|FACESTREAM_TIMEOUT|timeout|TURN|turn' README.md src pyproject.toml

Repository: philipp-eisen/facestream

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
import pathlib
import tomllib

manifest = tomllib.loads(pathlib.Path("pyproject.toml").read_text())
declared = {
    dependency.split(/[<>=!~; ]/, 1)[0]
    for dependency in manifest["project"]["dependencies"]
}
imports = set()
for path in pathlib.Path("src").rglob("*.py"):
    tree = ast.parse(path.read_text(), filename=str(path))
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            imports.update(alias.name.split(".", 1)[0] for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module:
            imports.add(node.module.split(".", 1)[0])

print("aiohttp declared:", "aiohttp" in declared)
print("aiohttp imported:", "aiohttp" in imports)
print("aiohttp dependency entries:", [
    dependency for dependency in manifest["project"]["dependencies"]
    if dependency.split(/[<>=!~; ]/, 1)[0] == "aiohttp"
])
PY

Repository: philipp-eisen/facestream

Length of output: 278


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
import pathlib
import re
import tomllib

manifest = tomllib.loads(pathlib.Path("pyproject.toml").read_text())
name_re = re.compile(r"[<>=!~; ]")
declared = {
    name_re.split(dependency, maxsplit=1)[0]
    for dependency in manifest["project"]["dependencies"]
}
imports = set()
for path in pathlib.Path("src").rglob("*.py"):
    tree = ast.parse(path.read_text(), filename=str(path))
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            imports.update(alias.name.split(".", 1)[0] for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module:
            imports.add(node.module.split(".", 1)[0])

print("aiohttp declared:", "aiohttp" in declared)
print("aiohttp imported:", "aiohttp" in imports)
print("aiohttp dependency entries:", [
    dependency for dependency in manifest["project"]["dependencies"]
    if name_re.split(dependency, maxsplit=1)[0] == "aiohttp"
])
PY

Repository: philipp-eisen/facestream

Length of output: 282


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
import pathlib
import re

manifest = pathlib.Path("pyproject.toml").read_text()
dependency_block = re.search(
    r"(?ms)^dependencies\s*=\s*\[(.*?)^\]",
    manifest,
)
if not dependency_block:
    raise SystemExit("project dependency block not found")

declared = set(re.findall(r'"([A-Za-z0-9_.-]+)', dependency_block.group(1)))

imports = set()
for path in pathlib.Path("src").rglob("*.py"):
    tree = ast.parse(path.read_text(), filename=str(path))
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            imports.update(alias.name.split(".", 1)[0] for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module:
            imports.add(node.module.split(".", 1)[0])

print("aiohttp declared:", "aiohttp" in declared)
print("aiohttp imported:", "aiohttp" in imports)
print("declared dependencies:", sorted(declared))
PY

Repository: philipp-eisen/facestream

Length of output: 399


Declare aiohttp as a direct runtime dependency.

If the deployment environment does not install aiohttp, importing facestream.main raises ModuleNotFoundError before the STUN fallback can run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/turn.py` at line 11, Declare aiohttp as a direct runtime
dependency in the project’s runtime dependency configuration so facestream.turn
can import it reliably when facestream.main loads. Keep the existing STUN
fallback behavior unchanged.

Comment thread src/facestream/turn.py
Comment on lines +22 to +25
# Comfortably longer than any session, and well inside what the API accepts.
CREDENTIAL_TTL_SECONDS = 86400

REQUEST_TIMEOUT_SECONDS = 10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map configuration declarations before inspecting timeout validation.
ast-grep outline src/facestream/config.py --items all
rg -n -C 4 'FACESTREAM_TIMEOUT|TIMEOUT|86400|CREDENTIAL_TTL' \
  src/facestream/config.py README.md src/facestream/turn.py

Repository: philipp-eisen/facestream

Length of output: 8400


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(pyproject\.toml|requirements[^/]*|setup\.cfg|setup\.py|Pipfile|uv\.lock|src/facestream/(config|turn|.*)\.py|README\.md)$' | head -80

printf '%s\n' '--- module outlines ---'
ast-grep outline src/facestream/turn.py --items all
ast-grep outline src/facestream/config.py --items all

printf '%s\n' '--- TURN and timeout references ---'
rg -n -C 5 'fetch_cloudflare_ice_servers|TURN_ENABLED|config\.TIMEOUT|TIMEOUT|CREDENTIAL_TTL_SECONDS|iceServers|ice_servers' src README.md

printf '%s\n' '--- source excerpts ---'
cat -n src/facestream/turn.py
sed -n '65,90p' src/facestream/config.py
sed -n '105,125p' src/facestream/config.py
sed -n '90,120p' README.md

printf '%s\n' '--- read-only behavioral probe ---'
python3 - <<'PY'
import os
from pathlib import Path

config = Path("src/facestream/config.py").read_text()
turn = Path("src/facestream/turn.py").read_text()

def env_int(name, default, env):
    return int(env.get(name, str(default)))

ttl = 86400
for value in ("3600", "86400", "86401", "172800"):
    timeout = env_int("FACESTREAM_TIMEOUT", 3600, {"FACESTREAM_TIMEOUT": value})
    print(f"FACESTREAM_TIMEOUT={value}: timeout={timeout}, ttl={ttl}, session_can_outlive_credentials={timeout > ttl}")

print("config_reads_timeout:", '"FACESTREAM_TIMEOUT"' in config)
print("turn_posts_ttl:", '"ttl": CREDENTIAL_TTL_SECONDS' in turn)
print("turn_references_config_timeout:", "config.TIMEOUT" in turn or "config import TIMEOUT" in turn)
PY

Repository: philipp-eisen/facestream

Length of output: 27753


🌐 Web query:

Cloudflare Realtime TURN generate-ice-servers ttl credential expiration allocation lifetime

💡 Result:

In Cloudflare Realtime TURN, you generate short-lived credentials for users using your long-term TURN key via the generate-ice-servers API endpoint [1][2]. TTL and Credential Expiration The ttl parameter in the generate-ice-servers request body defines the lifetime of the generated credentials in seconds [1][3]. - Maximum TTL: The maximum expiration time is 48 hours (172,800 seconds) [4][3]. Requests with a TTL exceeding this limit will be rejected by the API [3]. - Usage: You should set the TTL to be longer than the expected duration of the user's session (e.g., the length of a video call) [1][2]. Allocation Lifetime - There is no maximum duration limit for a TURN allocation itself [4][5]. - Maintenance: To keep an allocation active, the client must periodically send a Refresh request to the server, which must be authenticated with valid TURN credentials [4][5]. - Long-Running Sessions: Because credentials expire after a maximum of 48 hours, if a TURN allocation needs to persist for longer, your application must generate and provide new, valid credentials to the client at least every 48 hours to maintain the allocation [4][5]. - Expiration Impact: If TURN credentials expire while an allocation is in use, Cloudflare Realtime will stop billing and usage tracking, and the connection will be disconnected after a short delay [4]. API Endpoint The endpoint used to generate these credentials is: POST https://rtc.live.cloudflare.com/v1/turn/keys/$TURN_KEY_ID/credentials/generate-ice-servers [1][2] Credentials can be revoked before their TTL expires using the following endpoint [1][2]: POST https://rtc.live.cloudflare.com/v1/turn/keys/$TURN_KEY_ID/credentials/$USERNAME/revoke [1][2]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- deployment and session lifetime ---'
sed -n '50,75p' src/facestream/main.py
sed -n '136,225p' src/facestream/main.py

printf '%s\n' '--- declared dependencies ---'
rg -n -C 3 'dependencies|aiohttp|turn' pyproject.toml uv.lock | head -120

Repository: philipp-eisen/facestream

Length of output: 13797


Constrain FACESTREAM_TIMEOUT to 86,400 seconds or refresh TURN credentials for longer sessions.

When FACESTREAM_TURN is enabled, FACESTREAM_TIMEOUT has no upper bound, but the generated TURN credentials expire after 86,400 seconds. Cloudflare disconnects active allocations after credential expiration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/facestream/turn.py` around lines 22 - 25, Update the facestream timeout
handling associated with FACESTREAM_TIMEOUT and FACESTREAM_TURN so sessions
cannot outlive the 86,400-second CREDENTIAL_TTL_SECONDS, either by enforcing
that maximum timeout or by refreshing TURN credentials before expiration;
preserve existing behavior for shorter sessions.

Divuzki and others added 2 commits August 12, 2026 19:39
…hbvx

Tune for dedicated GPUs and add an OBS output mode
Deploying meant running modal from a laptop, and the checks written while
building the OBS support lived outside the repo. Both move into GitHub.

Workflows:

- CI on pull requests: ruff, unit tests, and a Chromium job that drives the
  real page against a stub backend. None of it needs a GPU or a Modal
  account, so it runs on a standard free runner.
- Deploy on push to main, or manually: lint and unit tests first, then the
  Cloudflare TURN secret is pushed into Modal when FACESTREAM_TURN is set,
  then modal deploy, then an optional /healthz check. Deploys are serialised
  rather than cancelled, since a half-applied deploy is worse than a stale
  one. The job targets a `production` environment so required reviewers can
  be added without touching the workflow.

All deploy-time configuration comes from repository variables with the same
defaults as the code, so hardware and autoscaling can change without a commit.
Modal reads MODAL_TOKEN_ID and MODAL_TOKEN_SECRET from the environment, so no
interactive login is involved.

Tests:

- 59 tests covering the paste-back's equivalence to insightface's full-frame
  version, the tracked-detection crop maths, what ProcessFrameTrack actually
  puts on the wire, ICE server selection against a stub Cloudflare endpoint,
  and the browser client end to end.
- tests/stub_server.py stands in for the deployment: same websocket protocol,
  real aiortc peer connection, real ProcessFrameTrack, with a CPU tint in
  place of the GPU swap.
- Adds pytest, pytest-asyncio, pytest-aiohttp, playwright and wsproto to the
  dev group. wsproto is there because uvicorn needs a websocket
  implementation for the stub.

README gains the accounts, secrets and variables needed to run this without
installing anything locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9EYcqE7xVeRrDnZorEoFA
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.

2 participants