Tune for dedicated GPUs and add an OBS output mode - #2
Conversation
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
|
Warning Review limit reached
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (11)
📝 WalkthroughWalkthroughThe 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. ChangesFace-swap runtime and client flow
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
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (3)
src/facestream/config.py (1)
162-177: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReport every active tuning value.
src/facestream/main.py:74logs this dictionary at startup. The report omitsBUFFER_CONTAINERS,MAX_CONTAINERS,TRACK_ROI_SCALE,DET_THRESH,STATS_INTERVAL, andINPUT_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 winRate 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 = 0in__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 winGuard 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
📒 Files selected for processing (6)
README.mdsrc/facestream/config.pysrc/facestream/faceswap.pysrc/facestream/main.pysrc/facestream/track.pyweb/index.html
| 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") |
There was a problem hiding this comment.
🎯 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.
| 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.
| # 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) |
There was a problem hiding this comment.
🩺 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.
| # 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.
| @web_app.get("/healthz") | ||
| def healthz(): | ||
| return {"status": "ok", "config": config.describe()} | ||
|
|
There was a problem hiding this comment.
🔒 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.
| @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.
| try: | ||
| data = json.loads(raw) | ||
| except json.JSONDecodeError: | ||
| logger.error("Received invalid JSON: %s", data) | ||
| logger.error("Received invalid JSON: %s", raw) | ||
| continue |
There was a problem hiding this comment.
🔒 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.
| 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.
| 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 | ||
|
|
There was a problem hiding this comment.
🩺 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.
| 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); |
There was a problem hiding this comment.
🩺 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.
| 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 | ||
| ); | ||
| } |
There was a problem hiding this comment.
🩺 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.htmlRepository: 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.htmlRepository: 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.
| 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); |
There was a problem hiding this comment.
🎯 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
README.mdsrc/facestream/config.pysrc/facestream/faceswap.pysrc/facestream/main.pysrc/facestream/track.pysrc/facestream/turn.pyweb/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
|
|
||
| 2. Put them in a Modal secret named `facestream`: | ||
|
|
||
| ``` |
There was a problem hiding this comment.
🎯 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.mdRepository: 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.pyRepository: 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 webRepository: 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
| 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 |
There was a problem hiding this comment.
🩺 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.
|
|
||
| import logging | ||
|
|
||
| import aiohttp |
There was a problem hiding this comment.
🩺 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.tomlRepository: 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"
])
PYRepository: 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"
])
PYRepository: 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))
PYRepository: 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.
| # Comfortably longer than any session, and well inside what the API accepts. | ||
| CREDENTIAL_TTL_SECONDS = 86400 | ||
|
|
||
| REQUEST_TIMEOUT_SECONDS = 10 |
There was a problem hiding this comment.
🩺 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.pyRepository: 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)
PYRepository: 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:
- 1: https://developers.cloudflare.com/realtime/turn/generate-credentials/
- 2: https://developers.cloudflare.com/realtime/turn/generate-credentials/index.md
- 3: https://github.com/cloudflare/skills/blob/main/skills/cloudflare/references/turn/api.md
- 4: https://developers.cloudflare.com/realtime/turn/faq/
- 5: https://developers.cloudflare.com/realtime/turn/faq/index.md
🏁 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 -120Repository: 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.
…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
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:
OBS:
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
Bug Fixes
Documentation