Skip to content

fix(plugins/bithuman): make Pillow optional + close AvatarRunner in aclose()#6191

Open
sgu-bithuman wants to merge 2 commits into
livekit:mainfrom
sgu-bithuman:fix/bithuman-pil-optional-and-aclose-runner
Open

fix(plugins/bithuman): make Pillow optional + close AvatarRunner in aclose()#6191
sgu-bithuman wants to merge 2 commits into
livekit:mainfrom
sgu-bithuman:fix/bithuman-pil-optional-and-aclose-runner

Conversation

@sgu-bithuman

Copy link
Copy Markdown
Contributor

Two small fixes for the livekit-plugins-bithuman avatar plugin, found while validating a self-hosted bitHuman essence integration.

1. Pillow is an undeclared dependency

avatar.py does from PIL import Image at module top, but Pillow is not in the package's dependencies — so a clean pip install livekit-plugins-bithuman fails to import the plugin:

ModuleNotFoundError: No module named 'PIL'

PIL is used only for cloud-mode avatar_image handling; local mode (model_path) never touches it. This makes the import optional (try/except ModuleNotFoundErrorImage = None) and guards the three isinstance(..., Image.Image) call sites, so local-mode users don't need Pillow. (from __future__ import annotations already makes the Image.Image type hints lazy.)

Verified: with Pillow uninstalled, from livekit.plugins.bithuman import AvatarSession imports and a local AvatarSession(model_path=..., api_secret=...) constructs successfully.

2. AvatarSession.aclose() leaks the AvatarRunner

aclose() calls super().aclose() (which only removes the room participant) + runtime.cleanup(), but never closes self._avatar_runner. That leaves the runner's _read_audio/_forward_video tasks, the AVSynchronizer, and the rust AudioSource/VideoSource open — a native resource leak that accumulates per session/reconnect. Now aclose() awaits self._avatar_runner.aclose() first.

Both changes are confined to avatar.py. No behavior change for cloud mode (Pillow present) or for the happy-path local stream.

🤖 Generated with Claude Code

@sgu-bithuman sgu-bithuman requested a review from a team as a code owner June 23, 2026 08:50

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

Open in Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 FormData object reuse across retries may cause issues

In _send_request_with_retry (line 589), when form_data is provided, the same aiohttp.FormData object is reused across retry iterations. After the first attempt consumes the form data (reading the BytesIO streams to EOF), subsequent retries may send empty or partial payloads because the stream positions are not reset. This is a pre-existing issue not introduced by this PR, but it affects the new _send_formdata_request code path added in line 564. Worth investigating whether aiohttp handles this internally or whether FormData needs to be reconstructed for each retry.

(Refers to lines 589-612)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed this is a real issue, and as you note it's pre-existing (the FormData/BytesIO reuse predates this PR) and lives in the cloud-mode formdata path, which is unrelated to this PR's scope (making Pillow optional + closing the AvatarRunner in aclose). To keep this PR focused and easy to review, I'd prefer to address it in a follow-up — the clean fix is to reconstruct the FormData per retry attempt (e.g. pass a builder into _send_request_with_retry rather than a pre-built FormData), since aiohttp reads the streams to EOF on the first send. Happy to fold it into this PR instead if you'd rather it ship together — let me know your preference.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

…close()

Two fixes for the bithuman avatar plugin:

1. Pillow is imported at module top (`from PIL import Image`) but is NOT a
   declared dependency, so a clean install fails to import the plugin. PIL is
   only used for cloud-mode `avatar_image` handling; local mode (`model_path`)
   never touches it. Make the import optional (`try/except ModuleNotFoundError`)
   and guard the `isinstance(..., Image.Image)` checks, so local-mode users
   don't need Pillow.

2. `AvatarSession.aclose()` never closed the `AvatarRunner`: the base aclose()
   only removes the room participant, leaving the runner's read/forward tasks,
   the AV synchronizer, and the rust audio/video sources open — a native leak
   that accumulates per session/reconnect. Close it in aclose().
@sgu-bithuman sgu-bithuman force-pushed the fix/bithuman-pil-optional-and-aclose-runner branch from 5647b44 to 458fdd5 Compare June 23, 2026 09:25
Addresses Devin review on livekit#6191: with Pillow optional (Image = None), the
file-path branch still called Image.open(avatar_image) -> AttributeError when
a local image path is passed in cloud mode without Pillow. Raise a clear
BitHumanException pointing the user to `pip install Pillow` instead.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review

Comment on lines +639 to +641
if self._avatar_runner is not None:
await self._avatar_runner.aclose()
self._avatar_runner = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Pre-existing naming inconsistency in AvatarRunner could cause AttributeError during partial-init cleanup

The new cleanup code at lines 639-641 calls self._avatar_runner.aclose(). Inside AvatarRunner.aclose() (livekit-agents/livekit/agents/voice/avatar/_runner.py:196), the attribute self._read_audio_atask is accessed. However, in AvatarRunner.__init__() at _runner.py:69, the attribute is declared as self._read_audio_task (without the 'a' before 'task'), while start() at _runner.py:92 creates self._read_audio_atask (with the 'a'). If AvatarRunner.start() were to fail after the runner is assigned to self._avatar_runner but before line 92 executes, a subsequent aclose() call would raise AttributeError. In practice this is extremely unlikely because the lines in start() before the task creation are simple event listener registrations that virtually never fail. Still, the naming inconsistency is a latent defect worth cleaning up.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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