Add optional SwarmUI service using Deck ComfyUI API
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build
|
||||
ARG SWARM_REV=de7b834b4aabc6b0239acb76940833277445fe51
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone https://github.com/mcmonkeyprojects/SwarmUI.git /swarm && git -C /swarm checkout ${SWARM_REV}
|
||||
WORKDIR /swarm
|
||||
# No local llama inference backend packages in this frontend-only image.
|
||||
RUN sed -i '/LLamaSharp.Backend.Cpu/d; /LLamaSharp.Backend.Cuda12/d' src/SwarmUI.deps.props
|
||||
COPY patch-source.py /tmp/patch-source.py
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 && python3 /tmp/patch-source.py
|
||||
RUN dotnet build src/SwarmUI.csproj -c Release -o src/bin/live_release
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 ffmpeg git ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /swarm /swarm
|
||||
COPY proxy.py entrypoint.py /integration/
|
||||
WORKDIR /swarm
|
||||
ENV SWARM_PORT=8125 SWARM_PROXY_PORT=8126 SWARM_DECK_PORT=8120
|
||||
USER 65534:65534
|
||||
ENTRYPOINT ["python3","/integration/entrypoint.py"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# SwarmUI als optionaler Dienst
|
||||
|
||||
SwarmUI ist eine Weboberfläche mit `ComfyUI API By URL`; Deck startet und beendet die vorhandene gemeinsame ComfyUI-Laufzeit im Videomodus. Der UI-Container hat keine GPU und installiert weder PyTorch noch eine weitere ComfyUI-Instanz. Im LLM-Modus ist sein Backend `idle`. Gewichte werden erst bei Generierung geladen.
|
||||
|
||||
## Installation auf der aktuellen Athena-Testinstallation
|
||||
|
||||
Voraussetzungen: Docker, laufendes Deck unter `/opt/athena-deck-dev/runtime`, dessen Endpoint auf 8120 und installierte ComfyUI-Laufzeit. Vorher Deck und Swarm-Code auf denselben Stand aktualisieren.
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /opt/athena-swarm-ui/source
|
||||
sudo cp deploy/swarm-ui/* /opt/athena-swarm-ui/source/
|
||||
sudo docker build -t athena-swarm-ui:de7b834 /opt/athena-swarm-ui/source
|
||||
sudo python3 /opt/athena-swarm-ui/source/install.py
|
||||
```
|
||||
|
||||
Der Installer prüft die freien Ports, setzt die Labels, verlinkt vorhandene LTX-2.5-Komponenten ohne Gewichte zu kopieren, kopiert die offiziellen Swarm-Comfy-Knoten aus dem gebauten Image und ergänzt OpenCV 4.12.0.88 und imageio-ffmpeg 0.6.0 separat unter `state/video/swarm-python`. Kein erneuter ComfyUI-Build. Bestehende UI-Einstellungen bleiben erhalten. `--replace` ersetzt ausschließlich den mit `application=swarm-ui` markierten Container.
|
||||
|
||||
## Zugriff und Betrieb
|
||||
|
||||
- UI: `127.0.0.1:8125`; lokaler Transportproxy: `127.0.0.1:8126`.
|
||||
- Backendadresse in Swarm: `http://127.0.0.1:8126`, Typ `ComfyUI API By URL`, `AllowIdle=true`.
|
||||
- Zuerst in Deck auf Video wechseln. Dann Swarm öffnen und vorhandenes LTX-Modell wählen. Auflösung, Länge und weitere Generierungsparameter werden in Swarm gesetzt.
|
||||
- Start/Stopp: `docker start athena-swarm-ui` / `docker stop athena-swarm-ui`. In Weitere Dienste zeigt Deck den Zustand und den Link zur Oberfläche.
|
||||
- Ausgaben und UI-Daten: `/opt/athena-swarm-ui/Output`, `Data`, `dlbackend` (schreibbar).
|
||||
- Modelle: Deck `state/video/comfy-work/models` → `/swarm/Models`, Deck `state/models` → `/var/lib/deck/models`, beide nur lesbar.
|
||||
- Authentisierung: eigenes Geheimnis `state/video/comfy-client-token` → `/run/secrets/comfy-token`, nur lesbar. Gilt ausschließlich im Videomodus, kein Zugriff auf die Deck-Verwaltungs-API. Proxy ergänzt nur HTTP/WebSocket-Authentisierung; die ComfyUI-API bleibt unverändert.
|
||||
- Host-Netzwerk dient ausschließlich der Loopback-Verbindung zum Deck-Endpoint. UI und Proxy binden ausschließlich an 127.0.0.1; keine Firewalländerung.
|
||||
|
||||
Mac-Tunnel, offen lassen:
|
||||
|
||||
```sh
|
||||
ssh -N -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes -o ExitOnForwardFailure=yes -L 8125:127.0.0.1:8125 root@192.168.1.212
|
||||
```
|
||||
|
||||
Danach http://127.0.0.1:8125/. Der Installer ist bewusst auf diese parallele Athena-Testinstallation zugeschnitten, kein universeller Debian-Installer.
|
||||
|
||||
## Version und Integration
|
||||
|
||||
Upstream SwarmUI ist auf `de7b834b4aabc6b0239acb76940833277445fe51` fixiert. `patch-source.py` hält drei nachvollziehbare Anpassungen fest: LTX-Textencoder verwendet Decks Loader auf der RTX 3060 und den vorhandenen BF16-Dateinamen; HTTP-Fehler werden korrekt als inaktives Backend erkannt; beim Wechsel von idle zu running werden die verfügbaren Knoten neu eingelesen. Der Transformer bleibt bei ComfyUI auf der bevorzugten RTX 5080 mit dessen Speicherverwaltung. Native llama.cpp-Bibliotheken im UI-Build werden entfernt.
|
||||
|
||||
Labels: `io.athena-deck.managed=true`, `io.athena-deck.role=application`, `io.athena-deck.application=swarm-ui`. Nur dieser Dienst wird ergänzt, vorhandene Router-, WireGuard- und LTX-DeskWEB-Dienste bleiben unberührt.
|
||||
|
||||
Prüfung: UI, Label-Erkennung, authentisierte native ComfyUI-API, WebSocket-Handshake und Backend-Aktivierung werden geprüft. Eine erfolgreiche vollständige Videogenerierung mit den BF16-Gewichten wird damit noch nicht behauptet. Swarm unterstützt weitere Funktionen, deren optionale Knoten/Modelle nicht automatisch installiert werden.
|
||||
|
||||
Referenz: https://github.com/mcmonkeyprojects/SwarmUI/blob/master/src/BuiltinExtensions/ComfyUIBackend/README.md
|
||||
@@ -0,0 +1,13 @@
|
||||
import os,subprocess,signal
|
||||
proxy=subprocess.Popen(['python3','/integration/proxy.py'])
|
||||
app=subprocess.Popen(['dotnet','src/bin/live_release/SwarmUI.dll','--host','127.0.0.1','--port',os.environ.get('SWARM_PORT','8125'),'--launch_mode','none','--data_dir','/swarm/Data','--loglevel','Warning'])
|
||||
def stop(*_):
|
||||
for process in (app,proxy):
|
||||
if process.poll() is None:process.terminate()
|
||||
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
|
||||
try:app.wait()
|
||||
finally:
|
||||
stop()
|
||||
for process in (app,proxy):
|
||||
try:process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:process.kill();process.wait()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Explicit optional SwarmUI service. Run on Athena as root after image build."""
|
||||
import json,os,secrets,socket,subprocess,sys
|
||||
from pathlib import Path
|
||||
base=Path('/opt/athena-swarm-ui');deck=Path('/opt/athena-deck-dev/runtime/state');name='athena-swarm-ui'
|
||||
def run(*args):return subprocess.check_output(args,text=True).strip()
|
||||
existing=subprocess.run(['docker','inspect',name],capture_output=True,text=True)
|
||||
if existing.returncode==0:
|
||||
item=json.loads(existing.stdout)[0];labels=item['Config'].get('Labels',{})
|
||||
if '--replace' not in sys.argv or labels.get('io.athena-deck.application')!='swarm-ui':raise SystemExit('Container exists. --replace only accepts the managed swarm-ui service.')
|
||||
run('docker','rm','-f',name)
|
||||
for port in (8125,8126):
|
||||
with socket.socket() as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);sock.bind(('127.0.0.1',port))
|
||||
for folder in ('Data','Output','dlbackend'):
|
||||
p=base/folder;p.mkdir(parents=True,exist_ok=True);os.chown(p,65534,65534)
|
||||
video=deck/'video';video.mkdir(exist_ok=True);token=video/'comfy-client-token'
|
||||
if not token.exists():token.write_text(secrets.token_urlsafe(48)+'\n')
|
||||
os.chmod(token,0o600);os.chown(token,65534,65534)
|
||||
# Reuse Deck's ComfyUI; install only the official frontend nodes and OpenCV dependency.
|
||||
nodes=video/'swarm-comfy-nodes';nodes.mkdir(exist_ok=True)
|
||||
temporary=run('docker','create','athena-swarm-ui:de7b834')
|
||||
try:run('docker','cp',temporary+':/swarm/src/BuiltinExtensions/ComfyUIBackend/ExtraNodes/.',str(nodes))
|
||||
finally:run('docker','rm',temporary)
|
||||
subprocess.check_call(['docker','exec','athena-deck-dev','/opt/deck-image-python/bin/python','-m','pip','install','--upgrade','--no-cache-dir','--no-deps','--target','/var/lib/deck/video/swarm-python','opencv-python-headless==4.12.0.88','imageio-ffmpeg==0.6.0'])
|
||||
for p in [nodes,*nodes.rglob('*')]:os.chown(p,65534,65534)
|
||||
work=video/'comfy-work/models'
|
||||
for folder in ('diffusion_models','text_encoders','vae','latent_upscale_models','loras','Stable-Diffusion','Lora','VAE','Embeddings','controlnet','model_patches','clip','clip_vision','upscale_models','tensorrt','unet'):
|
||||
p=work/folder;p.mkdir(parents=True,exist_ok=True);os.chown(p,65534,65534)
|
||||
# Only link the maintained LTX recipe; no model copies or downloads.
|
||||
entries=[]
|
||||
for metadata in (deck/'models').glob('*/entry.json'):
|
||||
item=json.loads(metadata.read_text());item['id']=metadata.parent.name;entries.append(item)
|
||||
main=next((x for x in entries if x.get('repo')=='Lightricks/LTX-2.5' and x.get('file')=='diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors'),None)
|
||||
if main:
|
||||
for item in entries:
|
||||
if item.get('repo')!=main['repo'] or item.get('revision')!=main['revision']:continue
|
||||
filename=Path(item['file']);folder=filename.parts[0]
|
||||
if folder not in ('diffusion_models','text_encoders','vae','latent_upscale_models'):continue
|
||||
source=deck/'models'/item['id']/('model'+filename.suffix)
|
||||
if not source.is_file():continue
|
||||
link=work/folder/filename.name
|
||||
if not link.exists() and not link.is_symlink():link.symlink_to('/var/lib/deck/models/'+item['id']+'/model'+filename.suffix)
|
||||
settings=base/'Data/Settings.fds'
|
||||
if not settings.exists():settings.write_text('IsInstalled: true\nLaunchMode: none\nNetwork:\n Host: 127.0.0.1\n Port: 8125\n PortCanChange: false\nPaths:\n ModelRoot: Models\n SDModelFolder: diffusion_models\n SDVAEFolder: vae\n SDLoraFolder: loras\n SDClipFolder: text_encoders\n')
|
||||
backends=base/'Data/Backends.fds'
|
||||
if not backends.exists():backends.write_text('0:\n type: comfyui_api\n title: Athena Deck ComfyUI\n enabled: true\n settings:\n Address: http://127.0.0.1:8126\n AllowIdle: true\n OverQueue: 0\n')
|
||||
for p in (settings,backends):os.chown(p,65534,65534)
|
||||
args=['docker','run','-d','--name',name,'--network','host','--restart','unless-stopped','--cap-drop','ALL','--security-opt','no-new-privileges:true','--memory','2g','--cpus','2','--label','io.athena-deck.managed=true','--label','io.athena-deck.role=application','--label','io.athena-deck.application=swarm-ui']
|
||||
for folder in ('Data','Output','dlbackend'):args+=['-v',f'{base/folder}:/swarm/{folder}:rw']
|
||||
args+=['-v',f'{work}:/swarm/Models:ro','-v',f'{deck/"models"}:/var/lib/deck/models:ro','-v',f'{token}:/run/secrets/comfy-token:ro','athena-swarm-ui:de7b834']
|
||||
run(*args);print('SwarmUI installed: http://127.0.0.1:8125 · API By URL · no GPU runtime')
|
||||
@@ -0,0 +1,20 @@
|
||||
from pathlib import Path
|
||||
p=Path('/swarm/src/BuiltinExtensions/ComfyUIBackend/WorkflowGeneratorModelSupport.cs');s=p.read_text()
|
||||
old=''' string loaderType = "CLIPLoader";
|
||||
if (model.EndsWith(".gguf"))'''
|
||||
new=''' if (type == "ltxv")
|
||||
{
|
||||
string deckLoader = g.CreateNode("DeckLTXTextEncoderLoader", new JObject() { ["clip_name"] = model });
|
||||
g.LoadingClip = [deckLoader, 0];
|
||||
return;
|
||||
}
|
||||
string loaderType = "CLIPLoader";
|
||||
if (model.EndsWith(".gguf"))'''
|
||||
assert old in s;s=s.replace(old,new).replace('"gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot-v2.safetensors"','"gemma4-12b-with-proj-ltx-2.5-bf16.safetensors"');p.write_text(s)
|
||||
p=Path('/swarm/src/BuiltinExtensions/ComfyUIBackend/ComfyUIAPIAbstractBackend.cs');s=p.read_text()
|
||||
old='return await NetworkBackendUtils.Parse<JType>(await HttpClient.GetAsync($"{APIAddress}/{url}", token));'
|
||||
new='return await NetworkBackendUtils.Parse<JType>((await HttpClient.GetAsync($"{APIAddress}/{url}", token)).EnsureSuccessStatusCode());'
|
||||
assert old in s;s=s.replace(old,new)
|
||||
old='SendGet<JObject>("features", cancel.Token).Wait();'
|
||||
new='SendGet<JObject>("features", cancel.Token).GetAwaiter().GetResult(); if (RawObjectInfo is null) { LoadValueSet().GetAwaiter().GetResult(); }'
|
||||
assert old in s;s=s.replace(old,new);p.write_text(s)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Direct authenticated transport to Deck's own ComfyUI, including WebSocket."""
|
||||
import http.client
|
||||
import socket
|
||||
import select
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||
import os
|
||||
MAX_BODY=256*1024*1024
|
||||
REQUEST_HEADERS={'content-type','accept','range','if-range','if-none-match','if-modified-since'}
|
||||
HOP_HEADERS={'connection','keep-alive','proxy-authenticate','proxy-authorization','te','trailer','transfer-encoding','upgrade'}
|
||||
|
||||
def relay(handler,port):
|
||||
if handler.headers.get('Transfer-Encoding'):raise ValueError('Content-Length erforderlich.')
|
||||
length=int(handler.headers.get('Content-Length','0'))
|
||||
if not 0<=length<=MAX_BODY:raise ValueError('Maximal 256 MiB pro Upload.')
|
||||
if handler.headers.get('Upgrade','').lower()=='websocket':
|
||||
with socket.create_connection(('127.0.0.1',port),timeout=10) as upstream:
|
||||
lines=[f'{handler.command} {handler.path} HTTP/1.1',f'Host: 127.0.0.1:{port}','Authorization: Bearer '+Path('/run/secrets/comfy-token').read_text().strip()]
|
||||
for key in ('Upgrade','Connection','Sec-WebSocket-Key','Sec-WebSocket-Version','Sec-WebSocket-Protocol'):
|
||||
if handler.headers.get(key):lines.append(key+': '+handler.headers[key])
|
||||
upstream.sendall(('\r\n'.join(lines)+'\r\n\r\n').encode());head=b''
|
||||
while b'\r\n\r\n' not in head:
|
||||
chunk=upstream.recv(4096)
|
||||
if not chunk:raise ValueError('WebSocket-Verbindung abgebrochen.')
|
||||
head+=chunk
|
||||
if len(head)>65536:raise ValueError('Ungültige WebSocket-Antwort.')
|
||||
handler.sent=True;handler.connection.sendall(head);upstream.settimeout(None)
|
||||
while True:
|
||||
readable,_,_=select.select([upstream,handler.connection],[],[],60)
|
||||
if not readable:continue
|
||||
for source in readable:
|
||||
data=source.recv(65536)
|
||||
if not data:return
|
||||
(handler.connection if source is upstream else upstream).sendall(data)
|
||||
else:
|
||||
upstream=http.client.HTTPConnection('127.0.0.1',port,timeout=3600)
|
||||
try:
|
||||
headers={k:v for k,v in handler.headers.items() if k.lower() in REQUEST_HEADERS};headers['Content-Length']=str(length);headers['Authorization']='Bearer '+Path('/run/secrets/comfy-token').read_text().strip()
|
||||
upstream.putrequest(handler.command,handler.path)
|
||||
for key,value in headers.items():upstream.putheader(key,value)
|
||||
upstream.endheaders();remaining=length
|
||||
while remaining:
|
||||
chunk=handler.rfile.read(min(1024*1024,remaining))
|
||||
if not chunk:raise ValueError('Unvollständiger Upload.')
|
||||
upstream.send(chunk);remaining-=len(chunk)
|
||||
response=upstream.getresponse();handler.sent=True;handler.send_response(response.status)
|
||||
for key,value in response.getheaders():
|
||||
if key.lower() not in HOP_HEADERS|{'server','date'}:handler.send_header(key,value)
|
||||
handler.send_header('Connection','close');handler.end_headers();handler.close_connection=True
|
||||
if handler.command!='HEAD':
|
||||
while chunk:=response.read(1024*1024):handler.wfile.write(chunk)
|
||||
finally:upstream.close()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self,*args):pass
|
||||
def route(self):
|
||||
try:relay(self,int(os.environ.get('SWARM_DECK_PORT','8120')))
|
||||
except (OSError,ValueError):
|
||||
if not getattr(self,'sent',False):self.send_error(503,'Deck video backend unavailable')
|
||||
do_GET=do_POST=do_PUT=do_PATCH=do_DELETE=do_HEAD=route
|
||||
if __name__=='__main__':ThreadingHTTPServer(('127.0.0.1',int(os.environ.get('SWARM_PROXY_PORT','8126'))),Handler).serve_forever()
|
||||
Reference in New Issue
Block a user