diff --git a/README.md b/README.md index ba32640..42a6153 100644 --- a/README.md +++ b/README.md @@ -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: ""}` 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. + +Optionales SwarmUI mit vorhandener ComfyUI-Laufzeit: [Installation und Betrieb](deploy/swarm-ui/README.md). diff --git a/deploy/swarm-ui/Dockerfile b/deploy/swarm-ui/Dockerfile new file mode 100644 index 0000000..1f3609a --- /dev/null +++ b/deploy/swarm-ui/Dockerfile @@ -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"] diff --git a/deploy/swarm-ui/README.md b/deploy/swarm-ui/README.md new file mode 100644 index 0000000..81a5727 --- /dev/null +++ b/deploy/swarm-ui/README.md @@ -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 diff --git a/deploy/swarm-ui/entrypoint.py b/deploy/swarm-ui/entrypoint.py new file mode 100644 index 0000000..079e7fc --- /dev/null +++ b/deploy/swarm-ui/entrypoint.py @@ -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() diff --git a/deploy/swarm-ui/install.py b/deploy/swarm-ui/install.py new file mode 100644 index 0000000..bad97d4 --- /dev/null +++ b/deploy/swarm-ui/install.py @@ -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') diff --git a/deploy/swarm-ui/patch-source.py b/deploy/swarm-ui/patch-source.py new file mode 100644 index 0000000..dbf2bbd --- /dev/null +++ b/deploy/swarm-ui/patch-source.py @@ -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(await HttpClient.GetAsync($"{APIAddress}/{url}", token));' +new='return await NetworkBackendUtils.Parse((await HttpClient.GetAsync($"{APIAddress}/{url}", token)).EnsureSuccessStatusCode());' +assert old in s;s=s.replace(old,new) +old='SendGet("features", cancel.Token).Wait();' +new='SendGet("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) diff --git a/deploy/swarm-ui/proxy.py b/deploy/swarm-ui/proxy.py new file mode 100644 index 0000000..fa5ec29 --- /dev/null +++ b/deploy/swarm-ui/proxy.py @@ -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() diff --git a/docker-ui.js b/docker-ui.js index f1368d5..d22342c 100644 --- a/docker-ui.js +++ b/docker-ui.js @@ -4,7 +4,7 @@ window.DockerUI=(()=>{ function html(services=false){return `
${services?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}

${services?'Weitere Dienste':'Docker'}

${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.'}

`;} 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'; - if(services){panel.innerHTML=`

Explizite Zuordnung

Beide Container-Labels müssen gesetzt sein:

io.athena-deck.managed=true\nio.athena-deck.role=application

Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.

Docker-Verbindung prüfen →
${!s.helper_available||!s.reachable?'

Docker nicht verbunden

'+esc(s.message)+'

':s.services.length?'
'+s.services.map(c=>`
DOCKER · DECK-ANWENDUNG

${esc(c.name)}

${esc(c.image)}

${esc(c.state)} · ${esc(c.status)}

`).join('')+'
':'

Noch keine Deck-Anwendungen

Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.

'}`;} + if(services){panel.innerHTML=`

Explizite Zuordnung

Beide Container-Labels müssen gesetzt sein:

io.athena-deck.managed=true\nio.athena-deck.role=application

Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.

Docker-Verbindung prüfen →
${!s.helper_available||!s.reachable?'

Docker nicht verbunden

'+esc(s.message)+'

':s.services.length?'
'+s.services.map(c=>`
DOCKER · DECK-ANWENDUNG

${esc(c.name)}

${esc(c.image)}

${esc(c.state)} · ${esc(c.status)}

${c.name==='athena-swarm-ui'?`

API By URL · Deck steuert die Video-Laufzeit

SwarmUI öffnen →

In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.

`:''}
`).join('')+'
':'

Noch keine Deck-Anwendungen

Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.

'}`;} else {panel.innerHTML=`
${s.installed?'INSTALLIERT':s.installed===false?'NICHT INSTALLIERT':'NICHT ERMITTELBAR'}

Docker Engine ${esc(s.version||'')}

${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}

${esc(s.message)}

${s.job?`

${esc(s.job.state)} · ${esc(s.job.phase)}

`:''}${!s.installed&&s.install_supported&&!running?'

':``}

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.

Weitere Dienste öffnen →
`; 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='';}};} } diff --git a/endpoint.py b/endpoint.py index 31a6a7d..89e2701 100644 --- a/endpoint.py +++ b/endpoint.py @@ -189,7 +189,8 @@ class APIHandler(BaseHTTPRequestHandler): ep=self.server.endpoint;admitted=False try: 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') 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: diff --git a/test_video_comfy.py b/test_video_comfy.py index c73ce21..9e12727 100644 --- a/test_video_comfy.py +++ b/test_video_comfy.py @@ -36,3 +36,11 @@ class VideoComfyTests(unittest.TestCase): def test_unknown_models_not_advertised(self): self.model['repo']='unknown/model';self.assertEqual(self.video.models(),[]) 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 ü')) diff --git a/video_comfy.py b/video_comfy.py index 6752551..e9648ce 100644 --- a/video_comfy.py +++ b/video_comfy.py @@ -5,6 +5,7 @@ from pathlib import Path, PurePosixPath import shutil import signal import socket +import secrets import subprocess import threading import time @@ -106,10 +107,14 @@ class VideoComfy: if link.is_symlink():link.unlink() 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') - 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) 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)) + 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')] 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) @@ -130,6 +135,11 @@ class VideoComfy: try:process.wait(timeout=15) except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait(timeout=10) 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): 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.')