Compare commits

..

10 Commits

Author SHA1 Message Date
f00b249708 Documentation for socket activation
Also: move github actions out of the way to prevent involuntary execution.
2025-12-05 18:11:27 +01:00
03be08f325 Socket activation working 2025-12-03 23:18:35 +01:00
943105ccef Allow reuse of socket (works without SELinux, probably no longer needed with proper workaround) 2025-12-03 23:00:05 +01:00
c9d4b636e6 2nd iteration fix socket activation 2025-12-03 22:45:28 +01:00
94b1730c24 First iteration fix socket activation 2025-12-03 22:37:16 +01:00
a860d809d1 Added dockerfile for ROCM container build 2025-12-03 22:29:52 +01:00
6918fd1efe Rework socket activation 2025-12-02 22:36:25 +01:00
a2f1dc2bc6 First attempt at socket activation 2025-12-02 22:20:22 +01:00
Dr.Lt.Data
ca7808f240 fix(user_manager): fix typo in move_userdata dest validation (#10967)
Check `dest` instead of `source` when validating destination path
in move_userdata endpoint.
2025-11-28 12:43:17 -08:00
Alexander Piskun
52e778fff3 feat(Kling-API-Nodes): add v2-5-turbo model to FirstLastFrame node (#10938) 2025-11-28 02:52:59 -08:00
29 changed files with 251 additions and 58 deletions

7
Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM docker.io/mixa3607/pytorch-gfx906:v2.9.0-rocm-7.0.2-20251102214219
WORKDIR /comfyui
COPY ./requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY ./ ./
ENTRYPOINT ["python", "main.py"]

View File

@@ -1,3 +1,10 @@
# Fork of comfy UI adding systemd socket activation support and a ROCM docker file
**The socket activation support is the result of vibe coding and not sufficiently validated tobe ready for general usage or contributing back upstream. Use at your own risk!**
Instructions for socket activation in [utils/socket_activation](utils/socket_activation/README.md).
Original readme starts below:
<div align="center">
# ComfyUI

View File

@@ -424,7 +424,7 @@ class UserManager():
return source
dest = get_user_data_path(request, check_exists=False, param="dest")
if not isinstance(source, str):
if not isinstance(dest, str):
return dest
overwrite = request.query.get("overwrite", 'true') != "false"

View File

@@ -4,8 +4,6 @@ For source of truth on the allowed permutations of request fields, please refere
- [Compatibility Table](https://app.klingai.com/global/dev/document-api/apiReference/model/skillsMap)
"""
from __future__ import annotations
from typing import Optional, TypeVar
import math
import logging
@@ -66,9 +64,7 @@ from comfy_api_nodes.util import (
poll_op,
)
from comfy_api.input_impl import VideoFromFile
from comfy_api.input.basic_types import AudioInput
from comfy_api.input.video_types import VideoInput
from comfy_api.latest import ComfyExtension, IO
from comfy_api.latest import ComfyExtension, IO, Input
KLING_API_VERSION = "v1"
PATH_TEXT_TO_VIDEO = f"/proxy/kling/{KLING_API_VERSION}/videos/text2video"
@@ -94,8 +90,6 @@ AVERAGE_DURATION_IMAGE_GEN = 32
AVERAGE_DURATION_VIDEO_EFFECTS = 320
AVERAGE_DURATION_VIDEO_EXTEND = 320
R = TypeVar("R")
MODE_TEXT2VIDEO = {
"standard mode / 5s duration / kling-v1": ("std", "5", "kling-v1"),
@@ -130,6 +124,8 @@ MODE_START_END_FRAME = {
"pro mode / 10s duration / kling-v1-6": ("pro", "10", "kling-v1-6"),
"pro mode / 5s duration / kling-v2-1": ("pro", "5", "kling-v2-1"),
"pro mode / 10s duration / kling-v2-1": ("pro", "10", "kling-v2-1"),
"pro mode / 5s duration / kling-v2-5-turbo": ("pro", "5", "kling-v2-5-turbo"),
"pro mode / 10s duration / kling-v2-5-turbo": ("pro", "10", "kling-v2-5-turbo"),
}
"""
Returns a mapping of mode strings to their corresponding (mode, duration, model_name) tuples.
@@ -296,7 +292,7 @@ def get_video_from_response(response) -> KlingVideoResult:
return video
def get_video_url_from_response(response) -> Optional[str]:
def get_video_url_from_response(response) -> str | None:
"""Returns the first video url from the Kling video generation task result.
Will not raise an error if the response is not valid.
"""
@@ -315,7 +311,7 @@ def get_images_from_response(response) -> list[KlingImageResult]:
return images
def get_images_urls_from_response(response) -> Optional[str]:
def get_images_urls_from_response(response) -> str | None:
"""Returns the list of image urls from the Kling image generation task result.
Will not raise an error if the response is not valid. If there is only one image, returns the url as a string. If there are multiple images, returns a list of urls.
"""
@@ -349,7 +345,7 @@ async def execute_text2video(
model_mode: str,
duration: str,
aspect_ratio: str,
camera_control: Optional[KlingCameraControl] = None,
camera_control: KlingCameraControl | None = None,
) -> IO.NodeOutput:
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_T2V)
task_creation_response = await sync_op(
@@ -394,8 +390,8 @@ async def execute_image2video(
model_mode: str,
aspect_ratio: str,
duration: str,
camera_control: Optional[KlingCameraControl] = None,
end_frame: Optional[torch.Tensor] = None,
camera_control: KlingCameraControl | None = None,
end_frame: torch.Tensor | None = None,
) -> IO.NodeOutput:
validate_prompts(prompt, negative_prompt, MAX_PROMPT_LENGTH_I2V)
validate_input_image(start_frame)
@@ -451,8 +447,8 @@ async def execute_video_effect(
model_name: str,
duration: KlingVideoGenDuration,
image_1: torch.Tensor,
image_2: Optional[torch.Tensor] = None,
model_mode: Optional[KlingVideoGenMode] = None,
image_2: torch.Tensor | None = None,
model_mode: KlingVideoGenMode | None = None,
) -> tuple[VideoFromFile, str, str]:
if dual_character:
request_input_field = KlingDualCharacterEffectInput(
@@ -499,13 +495,13 @@ async def execute_video_effect(
async def execute_lipsync(
cls: type[IO.ComfyNode],
video: VideoInput,
audio: Optional[AudioInput] = None,
voice_language: Optional[str] = None,
model_mode: Optional[str] = None,
text: Optional[str] = None,
voice_speed: Optional[float] = None,
voice_id: Optional[str] = None,
video: Input.Video,
audio: Input.Audio | None = None,
voice_language: str | None = None,
model_mode: str | None = None,
text: str | None = None,
voice_speed: float | None = None,
voice_id: str | None = None,
) -> IO.NodeOutput:
if text:
validate_string(text, field_name="Text", max_length=MAX_PROMPT_LENGTH_LIP_SYNC)
@@ -787,7 +783,7 @@ class KlingCameraControlT2VNode(IO.ComfyNode):
negative_prompt: str,
cfg_scale: float,
aspect_ratio: str,
camera_control: Optional[KlingCameraControl] = None,
camera_control: KlingCameraControl | None = None,
) -> IO.NodeOutput:
return await execute_text2video(
cls,
@@ -854,8 +850,8 @@ class KlingImage2VideoNode(IO.ComfyNode):
mode: str,
aspect_ratio: str,
duration: str,
camera_control: Optional[KlingCameraControl] = None,
end_frame: Optional[torch.Tensor] = None,
camera_control: KlingCameraControl | None = None,
end_frame: torch.Tensor | None = None,
) -> IO.NodeOutput:
return await execute_image2video(
cls,
@@ -965,15 +961,11 @@ class KlingStartEndFrameNode(IO.ComfyNode):
IO.String.Input("prompt", multiline=True, tooltip="Positive text prompt"),
IO.String.Input("negative_prompt", multiline=True, tooltip="Negative text prompt"),
IO.Float.Input("cfg_scale", default=0.5, min=0.0, max=1.0),
IO.Combo.Input(
"aspect_ratio",
options=[i.value for i in KlingVideoGenAspectRatio],
default="16:9",
),
IO.Combo.Input("aspect_ratio", options=["16:9", "9:16", "1:1"]),
IO.Combo.Input(
"mode",
options=modes,
default=modes[2],
default=modes[8],
tooltip="The configuration to use for the video generation following the format: mode / duration / model_name.",
),
],
@@ -1254,8 +1246,8 @@ class KlingLipSyncAudioToVideoNode(IO.ComfyNode):
@classmethod
async def execute(
cls,
video: VideoInput,
audio: AudioInput,
video: Input.Video,
audio: Input.Audio,
voice_language: str,
) -> IO.NodeOutput:
return await execute_lipsync(
@@ -1314,7 +1306,7 @@ class KlingLipSyncTextToVideoNode(IO.ComfyNode):
@classmethod
async def execute(
cls,
video: VideoInput,
video: Input.Video,
text: str,
voice: str,
voice_speed: float,
@@ -1471,7 +1463,7 @@ class KlingImageGenerationNode(IO.ComfyNode):
human_fidelity: float,
n: int,
aspect_ratio: KlingImageGenAspectRatio,
image: Optional[torch.Tensor] = None,
image: torch.Tensor | None = None,
) -> IO.NodeOutput:
validate_string(prompt, field_name="prompt", min_length=1, max_length=MAX_PROMPT_LENGTH_IMAGE_GEN)
validate_string(negative_prompt, field_name="negative_prompt", max_length=MAX_PROMPT_LENGTH_IMAGE_GEN)

115
server.py
View File

@@ -37,13 +37,42 @@ from app.user_manager import UserManager
from app.model_manager import ModelFileManager
from app.custom_node_manager import CustomNodeManager
from app.subgraph_manager import SubgraphManager
from typing import Optional, Union
from typing import Optional, Union, List, Tuple, Any
from api_server.routes.internal.internal_routes import InternalRoutes
from protocol import BinaryEventTypes
# Import cache control middleware
from middleware.cache_middleware import cache_control
class NoListenSocket(socket.socket):
"""
A socket that pretends to be already listening.
The overridden ``listen`` simply returns without calling the kernel.
"""
def listen(self, backlog: int = socket.SOMAXCONN, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
# If the socket is already in LISTEN state the kernel will reject a
# second listen() with EPERM (which is what triggered the SELinux
# denial). By turning it into a noop we avoid that system call.
# The socket is still usable by asyncio because it was created by
# systemd with ``listen`` already performed.
logging.debug("NoListenSocket.listen() called noop (socket already listening)")
return None
# Helper --------------------------------------------------------------------
def _fd_to_socket(fd: int) -> socket.socket:
"""
Convert a filedescriptor received from systemd into a socket that aiohttp
can use *without* performing a second listen().
"""
try:
sock = NoListenSocket(fileno=fd)
sock.setblocking(False)
return sock
except OSError as exc:
raise RuntimeError(f"Could not wrap fd {fd} as a NoListenSocket: {exc}") from exc
async def send_socket_catch_exception(function, message):
try:
await function(message)
@@ -1030,38 +1059,92 @@ class PromptServer():
await self.start_multi_address([(address, port)], call_on_start=call_on_start)
async def start_multi_address(self, addresses, call_on_start=None, verbose=True):
"""
Starts the aiohttp server. If systemd activation is detected the
provided ``addresses`` are ignored and the sockets returned by
``_get_systemd_sockets`` are used instead.
"""
runner = web.AppRunner(self.app, access_log=None)
await runner.setup()
ssl_ctx = None
scheme = "http"
if args.tls_keyfile and args.tls_certfile:
ssl_ctx = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_SERVER, verify_mode=ssl.CERT_NONE)
ssl_ctx = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_SERVER,
verify_mode=ssl.CERT_NONE)
ssl_ctx.load_cert_chain(certfile=args.tls_certfile,
keyfile=args.tls_keyfile)
scheme = "https"
# -----------------------------------------------------------------
# Systemd activation ------------------------------------------------
systemd_sockets = self._get_systemd_sockets()
if systemd_sockets:
if verbose:
logging.info("Starting server\n")
for addr in addresses:
address = addr[0]
port = addr[1]
logging.info("Systemd socket activation detected using supplied socket(s)")
for sock in systemd_sockets:
# ``sock.getsockname()`` can be 2tuple (IPv4) or 4tuple (IPv6)
# or a string (Unix). Normalise for logging.
try:
raw_name = sock.getsockname()
if isinstance(raw_name, tuple):
host = raw_name[0]
port = raw_name[1]
if sock.family == socket.AF_INET6:
host = f"[{host}]"
else:
# Unix domain socket just show the path.
host, port = raw_name, ""
except Exception:
host, port = "unknown", "unknown"
if verbose:
logging.info(f"GUI reachable at: {scheme}://{host}:{port}")
site = web.SockSite(runner, sock, ssl_context=ssl_ctx)
await site.start()
# Keep a reference useful for debugging / graceful shutdown
self.systemd_sockets = systemd_sockets
else:
# -----------------------------------------------------------------
# Classic TCPSite fallback -----------------------------------------
if verbose:
logging.info("Systemd activation not detected falling back to manual bind")
for address, port in addresses:
site = web.TCPSite(runner, address, port, ssl_context=ssl_ctx)
await site.start()
if not hasattr(self, 'address'):
self.address = address #TODO: remove this
if not hasattr(self, "address"):
self.address = address
self.port = port
if ':' in address:
address_print = "[{}]".format(address)
else:
address_print = address
# Nicely format IPv6 literals for the log line.
address_print = f"[{address}]" if ":" in address else address
if verbose:
logging.info("To see the GUI go to: {}://{}:{}".format(scheme, address_print, port))
logging.info(f"GUI reachable at: {scheme}://{address_print}:{port}")
if call_on_start is not None:
call_on_start(scheme, self.address, self.port)
# -----------------------------------------------------------------
if call_on_start:
await call_on_start()
def _get_systemd_sockets(self) -> List[socket.socket]:
sockets = []
if "LISTEN_FDS" not in os.environ or "LISTEN_PID" not in os.environ:
return sockets
listen_fds = int(os.getenv("LISTEN_FDS", "0"))
listen_pid = int(os.getenv("LISTEN_PID", "0"))
if listen_pid != os.getpid():
return sockets
for i in range(listen_fds):
fd = 3 + i
try:
sockets.append(_fd_to_socket(fd))
except Exception as e:
logging.error(f"Failed to convert fd {fd}: {e}")
os.unsetenv("LISTEN_FDS")
os.unsetenv("LISTEN_PID")
return sockets
def add_on_prompt_handler(self, handler):
self.on_prompt_handlers.append(handler)

View File

@@ -0,0 +1,40 @@
# Rootless podman container with Systemd Socket activation
## Idea
By passing in the socket from systemd we minimize resource use when not in use.
Since no other network access is required for operation, we can configure the container
with network=none and minimize the risk of the AI escaping.
## Set up
Optional, if you want to run this as a separate user
```
sudo useradd comfy
sudo machinectl shell comfy@
```
Check out this repository, navigate to its root directory and build the comfy
container with
```
podman build -t localhost/comfy:latest .
```
Place comfy.socket in ` ~/.config/systemd/user`, adjust ports and interfaces if needed.
Place comfy.container in `~/.config/containers/systemd`. Adjust paths for models and config if desired.
The files are in `utils/socket_activation`, next to this readme.
Put model files into the models directory (`~/models`).
Start the socket:
```
systemctl --user daemon-reload
systemctl --user enable --now comfy.socket
```
If you want to run the service also when the user is not logged in, enable lingering:
```
sudo loginctl enable-linger <user>
```
Check that you can access comfy in browser. For troubleshooting, use, e. g., `journalctl -xe`.

View File

@@ -0,0 +1,24 @@
[Unit]
Description=Comfy ui in a ROCM container
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/comfy:latest
#AutoRemove=yes
#PublishPort=8080:8080
Environment=ROCR_VISIBLE_DEVICES="GGPU-3b0c81617337ec1b"
Network=none
Volume=%h/comfy/models:/comfyui/models:ro,z
Volume=%h/comfy/input:/comfyui/input:Z
Volume=%h/comfy/output:/comfyui/output:Z
Volume=%h/comfy/user:/comfyui/user:Z
AddDevice=/dev/dri
AddDevice=/dev/kfd
Exec=
#[Service]
#Restart=always
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=llama socket
[Socket]
ListenStream=0.0.0.0:8095
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,24 @@
[Unit]
Description=Comfy ui in a ROCM container
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/comfy:latest
#AutoRemove=yes
#PublishPort=8080:8080
Network=none
Environment=ROCR_VISIBLE_DEVICES="GPU-3ce290c173497dfb"
Volume=%h/comfy/models:/comfyui/models:ro,z
Volume=%h/comfy/input2:/comfyui/input:Z
Volume=%h/comfy/output2:/comfyui/output:Z
Volume=%h/comfy/user2:/comfyui/user:Z
AddDevice=/dev/dri
AddDevice=/dev/kfd
Exec=
#[Service]
#Restart=always
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Comfy second instance socket
[Socket]
ListenStream=0.0.0.0:8096
[Install]
WantedBy=default.target