Add optional SwarmUI service using Deck ComfyUI API
This commit is contained in:
1 parent
706306d7b8
commit
18adaf4a52
11 files changed
+232
-3
No files matched your search
@@ -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).
|
||||||
@@ -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()
|
||||||
+1
-1
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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.')
|
||||||
|
|||||||
Reference in new issue
Block a user