Add optional SwarmUI service using Deck ComfyUI API

This commit is contained in:
Mikei386 committed 2026-09-30 13:21:45 +02:00
1 parent 706306d7b8
commit 18adaf4a52
11 files changed
+232 -3

No files matched your search

+2
View File
@@ -188,3 +188,5 @@ Danach `http://127.0.0.1:8120/` im Videomodus öffnen; zunächst unter `http://1
Interne Verwaltungs-API: `GET /api/v1/video` meldet Modelle, Komponenten, Blocker, Laufzeit und Prozessstatus; `POST /api/v1/video/service` mit `{id: "<Bibliotheks-ID>"}` wählt das Modell, mit `{id: ""}` hebt die Auswahl auf; `POST /api/v1/video/mode` mit `{mode: "video"}` oder `{mode: "llm"}` wechselt den GPU-Modus. Änderungen benötigen eine angemeldete Deck-Sitzung und `X-Athena-Deck: 1`. Gewichte der aktiven Auswahl bleiben gegen Bibliothekslöschung geschützt. Interne Verwaltungs-API: `GET /api/v1/video` meldet Modelle, Komponenten, Blocker, Laufzeit und Prozessstatus; `POST /api/v1/video/service` mit `{id: "<Bibliotheks-ID>"}` wählt das Modell, mit `{id: ""}` hebt die Auswahl auf; `POST /api/v1/video/mode` mit `{mode: "video"}` oder `{mode: "llm"}` wechselt den GPU-Modus. Änderungen benötigen eine angemeldete Deck-Sitzung und `X-Athena-Deck: 1`. Gewichte der aktiven Auswahl bleiben gegen Bibliothekslöschung geschützt.
Für reine App-Updates kann der Debian-Installer mit `--update --reuse-runtime` die installierten Laufzeiten aus dem bisherigen Deck-Image übernehmen. Diese Option ist für App-Änderungen gedacht; gewünschte Runtime-Updates werden weiterhin ausdrücklich gebaut. Regenerierbare Video-Laufzeiten und ComfyUI-Arbeitsdaten werden bei App-Updates nicht mehr in jedes Zustandsbackup kopiert. Für reine App-Updates kann der Debian-Installer mit `--update --reuse-runtime` die installierten Laufzeiten aus dem bisherigen Deck-Image übernehmen. Diese Option ist für App-Änderungen gedacht; gewünschte Runtime-Updates werden weiterhin ausdrücklich gebaut. Regenerierbare Video-Laufzeiten und ComfyUI-Arbeitsdaten werden bei App-Updates nicht mehr in jedes Zustandsbackup kopiert.
Optionales SwarmUI mit vorhandener ComfyUI-Laufzeit: [Installation und Betrieb](deploy/swarm-ui/README.md).
+18
View File
@@ -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"]
+45
View File
@@ -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
+13
View File
@@ -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()
+51
View File
@@ -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')
+20
View File
@@ -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)
+61
View File
@@ -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()
+1 -1
View File
@@ -4,7 +4,7 @@ window.DockerUI=(()=>{
function html(services=false){return `<div class="kicker">${services?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}</div><h1>${services?'Weitere Dienste':'Docker'}</h1><p>${services?'Zusätzliche Oberflächen werden getrennt von Modellen und Laufzeiten verwaltet. Hier erscheinen ausschließlich ausdrücklich für Deck markierte Anwendungscontainer.':'Optionale Umgebung für zusätzliche Studios. Native Modelllaufzeiten bleiben unabhängig davon.'}</p><section id="docker-panel"></section><p id="docker-message" role="status"></p>`;} function html(services=false){return `<div class="kicker">${services?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}</div><h1>${services?'Weitere Dienste':'Docker'}</h1><p>${services?'Zusätzliche Oberflächen werden getrennt von Modellen und Laufzeiten verwaltet. Hier erscheinen ausschließlich ausdrücklich für Deck markierte Anwendungscontainer.':'Optionale Umgebung für zusätzliche Studios. Native Modelllaufzeiten bleiben unabhängig davon.'}</p><section id="docker-panel"></section><p id="docker-message" role="status"></p>`;}
function bind(services=false){const panel=document.querySelector('#docker-panel'),message=document.querySelector('#docker-message');let signature='',pending=false; function bind(services=false){const panel=document.querySelector('#docker-panel'),message=document.querySelector('#docker-message');let signature='',pending=false;
async function refresh(){try{const s=await api();if(!panel.isConnected)return;const next=JSON.stringify(s);if(signature!==next){signature=next;const running=s.job?.state==='running'; async function refresh(){try{const s=await api();if(!panel.isConnected)return;const next=JSON.stringify(s);if(signature!==next){signature=next;const running=s.job?.state==='running';
if(services){panel.innerHTML=`<section class="card"><h2>Explizite Zuordnung</h2><p>Beide Container-Labels müssen gesetzt sein:</p><pre><code>io.athena-deck.managed=true\nio.athena-deck.role=application</code></pre><p class="small">Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.</p><a class="link" href="#docker-runtime">Docker-Verbindung prüfen →</a></section>${!s.helper_available||!s.reachable?'<section class="empty"><h2>Docker nicht verbunden</h2><p>'+esc(s.message)+'</p></section>':s.services.length?'<div class="grid">'+s.services.map(c=>`<section class="card"><span class="pill">DOCKER · DECK-ANWENDUNG</span><h2>${esc(c.name)}</h2><p>${esc(c.image)}</p><p><strong>${esc(c.state)}</strong> · ${esc(c.status)}</p></section>`).join('')+'</div>':'<section class="empty"><h2>Noch keine Deck-Anwendungen</h2><p>Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.</p></section>'}`;} if(services){panel.innerHTML=`<section class="card"><h2>Explizite Zuordnung</h2><p>Beide Container-Labels müssen gesetzt sein:</p><pre><code>io.athena-deck.managed=true\nio.athena-deck.role=application</code></pre><p class="small">Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.</p><a class="link" href="#docker-runtime">Docker-Verbindung prüfen →</a></section>${!s.helper_available||!s.reachable?'<section class="empty"><h2>Docker nicht verbunden</h2><p>'+esc(s.message)+'</p></section>':s.services.length?'<div class="grid">'+s.services.map(c=>`<section class="card"><span class="pill">DOCKER · DECK-ANWENDUNG</span><h2>${esc(c.name)}</h2><p>${esc(c.image)}</p><p><strong>${esc(c.state)}</strong> · ${esc(c.status)}</p>${c.name==='athena-swarm-ui'?`<p>API By URL · Deck steuert die Video-Laufzeit</p><a class="link" href="${esc(location.protocol+'//'+location.hostname+':8125/')}" target="_blank" rel="noopener">SwarmUI öffnen →</a><p class="small">In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.</p>`:''}</section>`).join('')+'</div>':'<section class="empty"><h2>Noch keine Deck-Anwendungen</h2><p>Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.</p></section>'}`;}
else {panel.innerHTML=`<section class="card"><span class="pill">${s.installed?'INSTALLIERT':s.installed===false?'NICHT INSTALLIERT':'NICHT ERMITTELBAR'}</span><h2>Docker Engine ${esc(s.version||'')}</h2><p>${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}</p><p>${esc(s.message)}</p>${s.job?`<p role="status">${esc(s.job.state)} · ${esc(s.job.phase)}</p>`:''}${!s.installed&&s.install_supported&&!running?'<label><input type="checkbox" id="docker-install-ack"> Docker Engine, Compose und Buildx auf diesem Server installieren. Docker richtet einen Dienst und Netzwerk-/Firewallregeln ein.</label><p><button id="docker-install" disabled>Docker installieren</button></p>':`<button disabled>${running?'Installation läuft …':s.installed?'Docker bereits vorhanden':'Installation hier nicht verfügbar'}</button>`}<p class="small">Keine automatische Aktualisierung vorhandener Docker-Installationen. NVIDIA Container Toolkit und Treiber sind separate Voraussetzungen für GPU-Container. Die Paketinstallation wird nicht mitten im Vorgang abgebrochen.</p><a class="link" href="#services">Weitere Dienste öffnen →</a></section>`; else {panel.innerHTML=`<section class="card"><span class="pill">${s.installed?'INSTALLIERT':s.installed===false?'NICHT INSTALLIERT':'NICHT ERMITTELBAR'}</span><h2>Docker Engine ${esc(s.version||'')}</h2><p>${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}</p><p>${esc(s.message)}</p>${s.job?`<p role="status">${esc(s.job.state)} · ${esc(s.job.phase)}</p>`:''}${!s.installed&&s.install_supported&&!running?'<label><input type="checkbox" id="docker-install-ack"> Docker Engine, Compose und Buildx auf diesem Server installieren. Docker richtet einen Dienst und Netzwerk-/Firewallregeln ein.</label><p><button id="docker-install" disabled>Docker installieren</button></p>':`<button disabled>${running?'Installation läuft …':s.installed?'Docker bereits vorhanden':'Installation hier nicht verfügbar'}</button>`}<p class="small">Keine automatische Aktualisierung vorhandener Docker-Installationen. NVIDIA Container Toolkit und Treiber sind separate Voraussetzungen für GPU-Container. Die Paketinstallation wird nicht mitten im Vorgang abgebrochen.</p><a class="link" href="#services">Weitere Dienste öffnen →</a></section>`;
const button=panel.querySelector('#docker-install'),ack=panel.querySelector('#docker-install-ack');if(button){ack.onchange=()=>button.disabled=pending||!ack.checked;button.onclick=async()=>{pending=true;button.disabled=true;try{await api('/install',{confirm:true});message.textContent='Docker-Erstinstallation gestartet.';}catch(error){message.textContent=error.message;}finally{pending=false;signature='';}};} const button=panel.querySelector('#docker-install'),ack=panel.querySelector('#docker-install-ack');if(button){ack.onchange=()=>button.disabled=pending||!ack.checked;button.onclick=async()=>{pending=true;button.disabled=true;try{await api('/install',{confirm:true});message.textContent='Docker-Erstinstallation gestartet.';}catch(error){message.textContent=error.message;}finally{pending=false;signature='';}};}
} }
+2 -1
View File
@@ -189,7 +189,8 @@ class APIHandler(BaseHTTPRequestHandler):
ep=self.server.endpoint;admitted=False ep=self.server.endpoint;admitted=False
try: try:
browser=bool(ep.video and ep.scheduler.gpu_mode=='video' and getattr(ep,'video_browser_auth',lambda h:False)(self.headers)) browser=bool(ep.video and ep.scheduler.gpu_mode=='video' and getattr(ep,'video_browser_auth',lambda h:False)(self.headers))
if not browser and not ep.authenticate(self.headers.get('Authorization','')):raise APIError('Gültiger API-Bearer-Token oder Deck-Anmeldung erforderlich.',401,'invalid_api_key') service_auth=bool(ep.video and callable(getattr(type(ep.video),'service_authenticated',None)) and ep.video.service_authenticated(self.headers.get('Authorization','')))
if not browser and not service_auth and not ep.authenticate(self.headers.get('Authorization','')):raise APIError('Gültiger API-Bearer-Token oder Deck-Anmeldung erforderlich.',401,'invalid_api_key')
origin=self.headers.get('Origin') origin=self.headers.get('Origin')
if origin and (not browser or origin not in ('http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host',''))):raise APIError('Cross-Origin-API-Zugriff nicht erlaubt.',403) if origin and (not browser or origin not in ('http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host',''))):raise APIError('Cross-Origin-API-Zugriff nicht erlaubt.',403)
with ep.lock: with ep.lock:
+8
View File
@@ -36,3 +36,11 @@ class VideoComfyTests(unittest.TestCase):
def test_unknown_models_not_advertised(self): def test_unknown_models_not_advertised(self):
self.model['repo']='unknown/model';self.assertEqual(self.video.models(),[]) self.model['repo']='unknown/model';self.assertEqual(self.video.models(),[])
with self.assertRaises(ValueError):self.video.select(self.model['id']) with self.assertRaises(ValueError):self.video.select(self.model['id'])
def test_service_token_only_authorizes_video(self):
self.video.root.mkdir();(self.video.root/'comfy-client-token').write_text('synthetic-service-secret')
self.assertFalse(self.video.service_authenticated('Bearer synthetic-service-secret'))
self.scheduler.gpu_mode='video'
self.assertTrue(self.video.service_authenticated('Bearer synthetic-service-secret'))
self.assertFalse(self.video.service_authenticated('Bearer wrong'))
self.assertFalse(self.video.service_authenticated('Bearer ü'))
+11 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path, PurePosixPath
import shutil import shutil
import signal import signal
import socket import socket
import secrets
import subprocess import subprocess
import threading import threading
import time import time
@@ -106,10 +107,14 @@ class VideoComfy:
if link.is_symlink():link.unlink() if link.is_symlink():link.unlink()
link.symlink_to(source) link.symlink_to(source)
node=work/'custom_nodes/deck_ltx';node.mkdir(parents=True,exist_ok=True);shutil.copyfile(Path(__file__).with_name('video_comfy_node.py'),node/'__init__.py') node=work/'custom_nodes/deck_ltx';node.mkdir(parents=True,exist_ok=True);shutil.copyfile(Path(__file__).with_name('video_comfy_node.py'),node/'__init__.py')
config={'deck':{'base_path':str(work),'custom_nodes':'custom_nodes',**{key:'models/'+key for key in paths}}};(work/'paths.json').write_text(json.dumps(config)) swarm_nodes=self.root/'swarm-comfy-nodes'
config={'deck':{'base_path':str(work),'custom_nodes':'custom_nodes',**{key:'models/'+key for key in paths}}};
if swarm_nodes.is_dir():config['swarm']={'base_path':str(self.root),'custom_nodes':'swarm-comfy-nodes'}
(work/'paths.json').write_text(json.dumps(config))
for directory in ('input','output','temp','user'):(work/directory).mkdir(exist_ok=True) for directory in ('input','output','temp','user'):(work/directory).mkdir(exist_ok=True)
with socket.socket() as sock:sock.bind(('127.0.0.1',0));self.port=sock.getsockname()[1] with socket.socket() as sock:sock.bind(('127.0.0.1',0));self.port=sock.getsockname()[1]
python,comfy=self.runtime.paths();env=dict(os.environ,CUDA_VISIBLE_DEVICES=','.join(g[0].strip() for g in ordered)) python,comfy=self.runtime.paths();env=dict(os.environ,CUDA_VISIBLE_DEVICES=','.join(g[0].strip() for g in ordered))
if (self.root/'swarm-python').is_dir():env['PYTHONPATH']=str(self.root/'swarm-python')+os.pathsep+env.get('PYTHONPATH','')
args=[str(python),str(comfy/'main.py'),'--listen','127.0.0.1','--port',str(self.port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(work/'paths.json')] args=[str(python),str(comfy/'main.py'),'--listen','127.0.0.1','--port',str(self.port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(work/'paths.json')]
for directory in ('input','output','temp','user'):args+=['--'+directory+'-directory',str(work/directory)] for directory in ('input','output','temp','user'):args+=['--'+directory+'-directory',str(work/directory)]
self.switch_phase='ComfyUI starten · Gewichte laden erst bei Anfrage';self.process=subprocess.Popen(args,env=env,cwd=comfy,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True) self.switch_phase='ComfyUI starten · Gewichte laden erst bei Anfrage';self.process=subprocess.Popen(args,env=env,cwd=comfy,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
@@ -130,6 +135,11 @@ class VideoComfy:
try:process.wait(timeout=15) try:process.wait(timeout=15)
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait(timeout=10) except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait(timeout=10)
self.process=None;self.port=None self.process=None;self.port=None
def service_authenticated(self,header):
if self.scheduler.gpu_mode!='video' or not header.startswith('Bearer '):return False
path=self.root/'comfy-client-token'
try:return secrets.compare_digest(header[7:].encode(),path.read_bytes().strip())
except OSError:return False
def relay(self,handler): def relay(self,handler):
from video_comfy_proxy import relay from video_comfy_proxy import relay
if not self.port or not self.process or self.process.poll() is not None:raise ValueError('ComfyUI nicht bereit.') if not self.port or not self.process or self.process.poll() is not None:raise ValueError('ComfyUI nicht bereit.')