Reuse shared ComfyUI runtime for Deck-owned LTX video

This commit is contained in:
Mikei386
2026-09-30 12:30:08 +02:00
parent ef80b2a0ca
commit fe296cebb1
16 changed files with 350 additions and 34 deletions
+12 -14
View File
@@ -165,28 +165,26 @@ Der Token wird ausschließlich serverseitig unter `models/huggingface.json` im D
Interne API (nur angemeldete Administratorsitzung): `GET /api/v1/huggingface` liefert ausschließlich `{configured: boolean}`; `POST` mit `{token: "hf_…"}` speichert/ersetzt, mit `{token: null}` entfernt. POST benötigt `X-Athena-Deck: 1`. Kein API-Endpunkt liefert den gespeicherten Token zurück. HTTP 401/403 von HF ergeben Hinweise auf Token, Leserechte und Modellfreigabe; die tatsächliche Freigabe wird beim Download geprüft. Interne API (nur angemeldete Administratorsitzung): `GET /api/v1/huggingface` liefert ausschließlich `{configured: boolean}`; `POST` mit `{token: "hf_…"}` speichert/ersetzt, mit `{token: null}` entfernt. POST benötigt `X-Athena-Deck: 1`. Kein API-Endpunkt liefert den gespeicherten Token zurück. HTTP 401/403 von HF ergeben Hinweise auf Token, Leserechte und Modellfreigabe; die tatsächliche Freigabe wird beim Download geprüft.
### Video: externe Dienste statt Deck-Profile ### Video: vorhandene ComfyUI-Laufzeit wiederverwenden
Deck verwaltet für Video ausschließlich den aktiven Dienst, Start/Stopp und die GPU-Freigabe. Unter **Video → Aktiver Videodienst** einen administrativ eingerichteten Dienst wählen; unter **Übersicht → Video** starten. **LLM** beendet den Videodienst. Modellauswahl, Komponenten, Auflösung, Dauer, Prompts und Generierung erfolgen ausschließlich über dessen Original-API und Oberfläche. Heute ist LTX Desktop angebunden; weitere containerisierte Videodienste lassen sich mit derselben Dienststeuerung registrieren. Keine Übersetzung zwischen APIs. Video nutzt dieselbe ComfyUI-Installation wie Bildgenerierung. Es wird keine zweite Python-/CUDA-/ComfyUI-Umgebung installiert. Der gepflegte Ausführungsweg ist derzeit LTX 2.5 Distilled BF16 aus `Lightricks/LTX-2.5`; unbekannte Varianten erhalten keine automatische Freigabe.
Der Video-Bereich ist wieder Teil der KI-Werkzeuge. Generierungsparameter bleiben ausschließlich in der Video-Anwendung. Das frühere Testformular, die eigene Video-Laufzeitinstallation und `/v1/videos`-Generierung sind entfernt. Alte Profil- und Modelldaten bleiben auf Platte erhalten, werden jedoch nicht mehr als aktive Videoprofile angeboten. `/v1/videos…` liefert HTTP 410 mit Verweis auf die Original-API. Der ursprüngliche Deck-Python-Video-Worker wird nicht mehr installiert oder gestartet. Unter **Video → Laufzeit / Aktives Modell** prüft Deck die Bibliothek: Transformer, Textencoder, Video-VAE, Audio-VAE und Spatial-Upsampler müssen vollständig sein und aus derselben Quellversion stammen. Vorhandene Dateien werden automatisch zugeordnet. **Komponenten prüfen / nachladen** bietet fehlende Dateien an; sie landen in derselben Download-Warteschlange. Bei erfüllten Voraussetzungen erscheint **Laufzeit bereit · lädt bei Anfrage**. Das bedeutet verfügbare Dateien und Anbindung, keine Garantie gegen OOM oder einen erfolgreich geprüften Render.
Die Steuerung erfolgt über den beschränkten Docker-Systemhelfer, ohne Docker-Socket in Deck. Root registriert Container-IDs, API-Adresse und Healthcheck in `/var/lib/athena-deck-docker/video-services.json`. Nur registrierte Dienste können gestartet/gestoppt werden; beliebige Docker-Aktionen sind ausgeschlossen. Siehe [Videodienste](docs/DOCKER_SERVICES.md#videodienste). **Als Videomodell aktivieren** wählt Gewichte ohne Generierungsparameter. **Übersicht → Video** beendet Decks andere GPU-Aufträge und startet einen eigenen ComfyUI-Prozess aus der gemeinsamen Installation. Die Gewichte werden erst bei einem ComfyUI-Workflow geladen. **LLM** beendet den kompletten Videoprozess und gibt dessen GPU-Speicher frei. Auch ein Deck-Neustart beendet den Deck-eigenen Prozess. Fremde Videodienste werden nur lesend geprüft und niemals von dieser Steuerung gestartet oder gestoppt.
Vor Videostart beendet Deck seine eigenen GPU-Aufträge, wartet auf Freigabe und verweigert den Start bei fremden GPU-Prozessen. Der alte Router bleibt unberührt. Im Videomodus wird der gemeinsame API-Port vollständig auf die Original-API des aktiven Videodienstes umgeschaltet. Chat/Bild/TTS/STT-Clients können diesen Port erst nach dem Rückwechsel auf LLM wieder verwenden. Beim Deck-Neustart wird ein laufender registrierter Videodienst erkannt und die GPU-Sperre wiederhergestellt; kein automatischer Dienststart und kein Abbruch externer Generierung beim bloßen Deck-Neustart. CUDA0 ist die RTX 5080 (Diffusion und VAE); CUDA1 die RTX 3060. Der mitgelieferte Node **Deck · LTX Textencoder auf RTX 3060** weist den Encoder ausdrücklich CUDA1 zu. Frei gestaltete Workflows mit anderen Loader-Nodes können davon abweichen. Große BF16-Gewichte nutzen zusätzlich CPU-Auslagerung; Transformer und Encoder passen nicht vollständig in 16 beziehungsweise 12 GiB VRAM. ComfyUI nutzt Low-VRAM-Modus mit 1,5 GiB GPU-Reserve. Auf Athena sind etwa 47 GiB System-RAM vorhanden, der Deck-Testcontainer ist auf 32 GiB begrenzt: Ein echter Render muss hinsichtlich RAM und VRAM separat geprüft werden.
LTX auf Athena: originale API am Host `http://127.0.0.1:41955`, bestehender SSH-Zugang über LTX Athena. Alternativer Tunnel vom Mac: Im Videomodus stellt der gemeinsame API-Port die native ComfyUI-API und Browseroberfläche einschließlich WebSocket bereit, ohne Parameterübersetzung. Im LLM-Modus bleibt er OpenAI-kompatibel. Während eines Wechsels gilt HTTP 503. API-Clients verwenden den Deck-Bearer-Token. Die Browseroberfläche verwendet die vorhandene Deck-Anmeldung; Cross-Origin-Zugriff ist gesperrt. Der Link **ComfyUI öffnen** erscheint in der Übersicht bei laufendem Videomodus. Die bestehende LTX DeskWEB-Oberfläche erwartet die Desktop-Backend-API und ist mit diesem ComfyUI-Port nicht direkt kompatibel.
Für Zugriff auf den API-/ComfyUI-Port 8120 vom Entwicklungsrechner:
```sh ```sh
ssh -N -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes -o ExitOnForwardFailure=yes -L 41956:127.0.0.1:41955 root@192.168.1.212 ssh -N -i /Users/mike_i386/.ssh/athena_key -o BatchMode=yes -o ExitOnForwardFailure=yes -L 8120:127.0.0.1:8120 root@192.168.1.212
``` ```
Dann API unter `http://127.0.0.1:41956`; Authentifizierung mit dem bestehenden LTX-Token, nicht dem Deck-Token. Keine Zugangsdaten in URLs. Der Port stellt eine API bereit, keine Browser-Studio-Oberfläche. Danach `http://127.0.0.1:8120/` im Videomodus öffnen; zunächst unter `http://127.0.0.1:8108/` anmelden. Auflösung, FPS, Dauer und Prompts werden im ComfyUI-Workflow eingestellt. Es gibt aktuell keinen automatisch vorbereiteten Video-Workflow und keinen integrierten Deck-Render-Test. Die native ComfyUI-Oberfläche bietet ihre Workflow-Templates; für die Gerätezuordnung den Deck-Textencoder-Node verwenden.
### Gemeinsamer API-Port: Protokollwechsel 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.
Im LLM-Modus bleibt der Deck-Endpunkt OpenAI-kompatibel. Im Videomodus werden HTTP-Pfad, Query, Methode, Inhalt, Antwortstatus und Antwortdaten unverändert an die registrierte Video-API weitergereicht. Es gibt keine Modell-/Parameterübersetzung. Beispiel auf Port 8120: `POST /api/generate`, `GET /api/generation/progress`, `POST /api/generate/cancel`. Kein `/v1`-Präfix für LTX. Während des Moduswechsels HTTP 503. 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.
Am gemeinsamen Port gilt weiterhin der Deck-Bearer-Token. Der root-eigene Helfer setzt intern den separaten LTX-Token; er gibt ihn niemals an Deck oder den Browser zurück. Der interne LTX-Port 41955 bleibt unverändert, sodass der bisherige LTX-Athena-Zugang weiter funktioniert. HTTP GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS und Range-Header werden transportiert. Request-Bodies benötigen Content-Length (maximal 256 MiB); kein WebSocket-Transport. Die geprüfte LTX-Desktop-API verwendet für Fortschritt HTTP-Polling.
Ein separates LTX-Webfrontend gehört als Docker-Anwendung unter Weitere Dienste, nicht die Video-Laufzeit-Auswahl. Dieses Webfrontend ist noch nicht implementiert. Der vorhandene LTX-Athena-Client benötigt neben API-Zugriff weiterhin seine Dateiübertragung über SSH/SCP; ein Browser-Port muss Electron-Dateifunktionen separat ersetzen. Weiterleitung allein stellt keine neuen Datei-Upload-/Downloadrouten im LTX-Backend bereit.
+2 -2
View File
@@ -30,7 +30,7 @@ window.CatalogUI = (() => {
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
} }
function renderLibrary() { function renderLibrary() {
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${executionHTML(x.execution)}${x.used_by_profiles?.length?`<p class="warning">Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.</p>`:""}<div class="card-actions">${x.profile_eligible===true?`<button data-profile-model="${x.id}">Profil anlegen</button>`:""}<button type="button" class="secondary" data-delete-model="${x.id}" ${x.used_by_profiles?.length?"disabled":""}>Datei löschen</button></div></article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>'; el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${executionHTML(x.execution)}${x.used_by_profiles?.length?`<p class="warning">Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.</p>`:""}<div class="card-actions">${x.profile_eligible===true?`<button data-profile-model="${x.id}">${kind==='video'?'Als Videomodell verwenden':'Profil anlegen'}</button>`:""}<button type="button" class="secondary" data-delete-model="${x.id}" ${x.used_by_profiles?.length?"disabled":""}>Datei löschen</button></div></article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
root.querySelectorAll('[data-delete-model]').forEach(b=>b.onclick=async()=>{const entry=state.entries.find(x=>x.id===b.dataset.deleteModel);if(!entry||!confirm(`Datei „${entry.file}“ (${size(entry.size)}) dauerhaft von Athena löschen?`))return;b.disabled=true;try{await api('/delete',{id:entry.id});message('Datei aus der Bibliothek gelöscht.');await refresh();}catch(error){message(error.message);b.disabled=false;}}); root.querySelectorAll('[data-delete-model]').forEach(b=>b.onclick=async()=>{const entry=state.entries.find(x=>x.id===b.dataset.deleteModel);if(!entry||!confirm(`Datei „${entry.file}“ (${size(entry.size)}) dauerhaft von Athena löschen?`))return;b.disabled=true;try{await api('/delete',{id:entry.id});message('Datei aus der Bibliothek gelöscht.');await refresh();}catch(error){message(error.message);b.disabled=false;}});
} }
function renderJob() { function renderJob() {
@@ -44,7 +44,7 @@ window.CatalogUI = (() => {
} }
async function refresh() { async function refresh() {
if (refreshing || !root.isConnected) return; refreshing = true; clearTimeout(timer); if (refreshing || !root.isConnected) return; refreshing = true; clearTimeout(timer);
try { const next = await api(''); if (!root.isConnected) return; const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(state.entries); const statusChanged = JSON.stringify(next) !== JSON.stringify(state); state = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } } try { const next = await api(''); if (!root.isConnected) return; const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(state.entries); const statusChanged = JSON.stringify(next) !== JSON.stringify(state); state = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>kind==='video'?fetch('/api/v1/video/service',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({id:b.dataset.profileModel})}).then(async r=>{const data=await r.json();if(!r.ok)throw Error(data.error);location.hash='video';VideoUI.runtime();}).catch(x=>alert(x.message)):Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } }
catch(error) { message(error.message); } catch(error) { message(error.message); }
finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); } finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); }
} }
+1 -1
View File
@@ -14,7 +14,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
WORKDIR /app WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/ COPY audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/ COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
+15
View File
@@ -0,0 +1,15 @@
ARG DECK_RUNTIME_IMAGE
FROM ${DECK_RUNTIME_IMAGE}
USER root
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
DECK_REQUIRE_SETUP=1 DECK_LOCAL_HARDWARE=1 DECK_LOCATION=Athena-Debian \
DECK_NETWORK_MODE=disabled
USER 65534:65534
CMD ["python3", "server.py", "--port", "8108"]
+10 -7
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] FILES = ['deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock']
def run(*args, check=True, interactive=False): def run(*args, check=True, interactive=False):
@@ -64,13 +64,15 @@ def free_port(port):
raise RuntimeError('Der gewünschte Loopback-Port ist bereits belegt.') from None raise RuntimeError('Der gewünschte Loopback-Port ist bereits belegt.') from None
def build(): def build(runtime_image=None):
digest = hashlib.sha256() digest = hashlib.sha256()
for name in FILES: for name in FILES:
digest.update(name.encode());digest.update((ROOT/name).read_bytes()) digest.update(name.encode());digest.update((ROOT/name).read_bytes())
tag = 'athena-deck-standalone:'+digest.hexdigest()[:16] tag = 'athena-deck-standalone:'+digest.hexdigest()[:16]
print('Eigenes Deck-Image bauen (keine Modelle oder llama.cpp-Builds).',flush=True) print('Eigenes Deck-Image bauen (keine Modelle oder llama.cpp-Builds).',flush=True)
result = run('docker','build','--label',LABEL+'=image','-t',tag,'-f',str(ROOT/'deploy/Dockerfile'),str(ROOT),interactive=True) dockerfile='deploy/Dockerfile.app-update' if runtime_image else 'deploy/Dockerfile'
extra=['--build-arg','DECK_RUNTIME_IMAGE='+runtime_image] if runtime_image else []
result = run('docker','build',*extra,'--label',LABEL+'=image','-t',tag,'-f',str(ROOT/dockerfile),str(ROOT),interactive=True)
return tag return tag
@@ -187,7 +189,7 @@ def install(args):
print('Generierter API-Token: geschützte Datei '+str(base/'bootstrap/api-token.txt')+'. In Passwortmanager übernehmen und Datei danach entfernen.') print('Generierter API-Token: geschützte Datei '+str(base/'bootstrap/api-token.txt')+'. In Passwortmanager übernehmen und Datei danach entfernen.')
def update(base, rollback=False, force=False, api_ports=None): def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=False):
config=load_manifest(base) config=load_manifest(base)
previous=owned(config['name'],base) previous=owned(config['name'],base)
if not previous: if not previous:
@@ -195,14 +197,14 @@ def update(base, rollback=False, force=False, api_ports=None):
selected_ports=config.get('api_ports',[]) if api_ports is None else parse_api_ports(api_ports) selected_ports=config.get('api_ports',[]) if api_ports is None else parse_api_ports(api_ports)
if config['port'] in selected_ports:raise RuntimeError('Verwaltungsport und API-Ports müssen verschieden sein.') if config['port'] in selected_ports:raise RuntimeError('Verwaltungsport und API-Ports müssen verschieden sein.')
for port in set(selected_ports)-set(config.get('api_ports',[])):free_port(port) for port in set(selected_ports)-set(config.get('api_ports',[])):free_port(port)
image=config.get('previous_image') if rollback else build() image=config.get('previous_image') if rollback else build(config['image'] if reuse_runtime else None)
if not image:raise RuntimeError('Kein vorheriger Build gespeichert.') if not image:raise RuntimeError('Kein vorheriger Build gespeichert.')
if image==config['image'] and selected_ports==config.get('api_ports',[]) and not force: if image==config['image'] and selected_ports==config.get('api_ports',[]) and not force:
print('Dieser Quellstand ist bereits installiert.');return print('Dieser Quellstand ist bereits installiert.');return
backup_name=config['name']+'-previous' backup_name=config['name']+'-previous'
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
stamp=str(time.time_ns()) stamp=str(time.time_ns())
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','router-verification-*')) shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*'))
was_running=previous['State']['Running'] was_running=previous['State']['Running']
if was_running:run('docker','stop','--time','15',config['name']) if was_running:run('docker','stop','--time','15',config['name'])
run('docker','rename',config['name'],backup_name) run('docker','rename',config['name'],backup_name)
@@ -235,6 +237,7 @@ def main():
parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone')) parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone'))
parser.add_argument('--name',default='athena-deck-standalone') parser.add_argument('--name',default='athena-deck-standalone')
parser.add_argument('--port',type=int,default=8110) parser.add_argument('--port',type=int,default=8110)
parser.add_argument('--reuse-runtime',action='store_true',help='Bei reinen App-Updates vorhandenes Deck-Image mit seinen Laufzeiten wiederverwenden; keine Laufzeitinstallation.')
parser.add_argument('--api-ports',help='Separater API-Port oder Bereich, z. B. 8120-8124. Installationsstandard: 8120-8124; Updates behalten den bisherigen Bereich.') parser.add_argument('--api-ports',help='Separater API-Port oder Bereich, z. B. 8120-8124. Installationsstandard: 8120-8124; Updates behalten den bisherigen Bereich.')
parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit für Messwerte und Fit-Prüfung nutzen; installiert keine Treiber.') parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit für Messwerte und Fit-Prüfung nutzen; installiert keine Treiber.')
args=parser.parse_args() args=parser.parse_args()
@@ -270,7 +273,7 @@ def main():
if item:run('docker','start',config['name']) if item:run('docker','start',config['name'])
else:launch(config) else:launch(config)
ready(config);print('Deck bereit.') ready(config);print('Deck bereit.')
elif args.update or args.rollback:update(args.directory,args.rollback,api_ports=args.api_ports) elif args.update or args.rollback:update(args.directory,args.rollback,api_ports=args.api_ports,reuse_runtime=args.reuse_runtime)
if __name__=='__main__': if __name__=='__main__':
try:main() try:main()
+5 -3
View File
@@ -188,8 +188,10 @@ class APIHandler(BaseHTTPRequestHandler):
def route(self): def route(self):
ep=self.server.endpoint;admitted=False ep=self.server.endpoint;admitted=False
try: try:
if not ep.authenticate(self.headers.get('Authorization','')):raise APIError('Gültiger API-Bearer-Token erforderlich.',401,'invalid_api_key') browser=bool(ep.video and ep.scheduler.gpu_mode=='video' and getattr(ep,'video_browser_auth',lambda h:False)(self.headers))
if self.headers.get('Origin'):raise APIError('Browserzugriff erfolgt über die Deck-Verwaltung; keine Cross-Origin-API-Freigabe.',403) 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')
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: with ep.lock:
if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping') if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping')
ep.inflight+=1;admitted=True ep.inflight+=1;admitted=True
@@ -197,7 +199,7 @@ class APIHandler(BaseHTTPRequestHandler):
if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching') if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching')
video=ep.video.status() video=ep.video.status()
if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready') if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready')
try:relay(self,video['selected']) try:ep.video.relay(self) if callable(getattr(type(ep.video),'relay',None)) else relay(self,video['selected'])
except ValueError as exc:raise APIError(str(exc)) from None except ValueError as exc:raise APIError(str(exc)) from None
return return
model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt'} model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt'}
+1 -1
View File
@@ -32,7 +32,7 @@ def assess(model, installed, profile=None):
result.update(message='Dieses TTS-Modell wird noch nicht ausgeführt. Deck unterstützt derzeit die festgelegte Qwen3-TTS-CustomVoice-Variante mit eingebauten Stimmen. MLX, Base mit Referenzstimme und andere TTS-Architekturen benötigen andere Anbindungen.',alternative=dict(route='#tts-runtime',label='Unterstützte TTS-Alternative ansehen (Modellwechsel)')) result.update(message='Dieses TTS-Modell wird noch nicht ausgeführt. Deck unterstützt derzeit die festgelegte Qwen3-TTS-CustomVoice-Variante mit eingebauten Stimmen. MLX, Base mit Referenzstimme und andere TTS-Architekturen benötigen andere Anbindungen.',alternative=dict(route='#tts-runtime',label='Unterstützte TTS-Alternative ansehen (Modellwechsel)'))
if video_recipe(model): if video_recipe(model):
result['family']='LTX 2.5' result['family']='LTX 2.5'
result.update(runtime='LTX-Pipelines · Distilled BF16',state='runtime_missing',label='Komponenten und Video-Laufzeit einrichten',route='#video-runtime',components=[x['label'] for x in video_recipe(model).values()],message='Videoprofil anlegen und dort Komponenten öffnen: passende Dateien aus der Version des Hauptmodells nachladen und zuordnen. LTX-Laufzeit unter Einstellungen → Video installieren; danach Videoprofil in der Übersicht auswählen.') result.update(runtime='ComfyUI · LTX 2.5 (gemeinsame Bildlaufzeit)',state='runtime_missing',label='Komponenten und Video-Laufzeit einrichten',route='#video',components=[x['label'] for x in video_recipe(model).values()],message='Vorhandene ComfyUI-Installation wiederverwenden. Im Bereich Video werden Gewichte und passende Komponenten derselben Quellversion geprüft; keine zweite Laufzeit installieren.')
if not result['runtime']:return result if not result['runtime']:return result
result['installed']=bool(installed.get(kind)) result['installed']=bool(installed.get(kind))
if kind!='chat' and result['state']!='workflow_missing': if kind!='chat' and result['state']!='workflow_missing':
+1 -1
View File
@@ -205,7 +205,7 @@ class Profiles:
except ValueError:pass except ValueError:pass
candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name] candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name]
requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates)) requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates))
if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='LTX-Pipelines · Distilled BF16 · Einstellungen → Laufzeiten → Video',runtime_installed=bool(getattr(self,'video_runtime_ready',lambda:False)()),message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Die Video-Laufzeit separat installieren und das Profil in der Übersicht auswählen. Vollständige Dateien sind keine Speicherzusage.') if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='ComfyUI · gemeinsame Bild- und Videolaufzeit',runtime_installed=bool(getattr(self,'video_runtime_ready',lambda:False)()),message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Vorhandene ComfyUI-Laufzeit wiederverwenden; Videomodell im Bereich Video aktivieren. Vollständige Dateien sind keine Speicherzusage.')
return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(), return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(),
message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.') message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.')
def assign(self,data): def assign(self,data):
+17 -2
View File
@@ -113,8 +113,10 @@ class Server(ThreadingHTTPServer):
self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler) self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
def stop_gpu_work(): def stop_gpu_work():
self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop() self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop()
self.video=Video(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video') from video_comfy import VideoComfy
self.video=VideoComfy(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime)
self.endpoint.video=self.video self.endpoint.video=self.video
self.profiles.video_runtime_ready=lambda:self.image_runtime.status().get("installed",False)
from dashboard_data import Dashboard from dashboard_data import Dashboard
self.dashboard=Dashboard(self,self.catalog.root.parent) self.dashboard=Dashboard(self,self.catalog.root.parent)
if self.endpoint.config['autostart']: if self.endpoint.config['autostart']:
@@ -124,6 +126,16 @@ class Server(ThreadingHTTPServer):
self.sessions = {} self.sessions = {}
self.login_attempts = [] self.login_attempts = []
self.auth_lock = threading.Lock() self.auth_lock = threading.Lock()
def video_browser_auth(headers):
from http.cookies import SimpleCookie
try:
cookies=SimpleCookie();cookies.load(headers.get('Cookie',''));token=cookies['deck_session'].value
except (KeyError,ValueError):return False
record=self.credentials.read()
with self.auth_lock:
session=self.sessions.get(token)
return bool(record and session and session['expires']>time.monotonic() and secrets.compare_digest(session['revision'],record['password']['hash']))
self.endpoint.video_browser_auth=video_browser_auth
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def log_message(self, *args): def log_message(self, *args):
@@ -262,7 +274,7 @@ class Handler(BaseHTTPRequestHandler):
return self.headers.get('Host') in allowed return self.headers.get('Host') in allowed
def execution_setup(self, model, profile=None): def execution_setup(self, model, profile=None):
installed={'stt':bool(self.server.runtime.status().get('active')),'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False),'video':False} installed={'stt':bool(self.server.runtime.status().get('active')),'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False),'video':self.server.image_runtime.status().get('installed',False)}
return execution_assess(model,installed,profile) return execution_assess(model,installed,profile)
def do_GET(self): def do_GET(self):
@@ -545,6 +557,9 @@ class Handler(BaseHTTPRequestHandler):
if set(data)!={'id'}:raise ValueError('Modelldatei-ID erwartet.') if set(data)!={'id'}:raise ValueError('Modelldatei-ID erwartet.')
with self.server.worker.lock: with self.server.worker.lock:
active=self.server.worker.profile['model_id'] if self.server.worker.profile else None active=self.server.worker.profile['model_id'] if self.server.worker.profile else None
video_rows=self.server.video.models()
selected=next((x for x in video_rows if x['model']['id']==self.server.video.selection.get('model_id')),None)
if selected and data['id'] in [selected['model']['id'],*selected['components'].values()]:raise ValueError('Datei wird vom aktiven Videomodell verwendet. Zuerst eine andere Video-Auswahl treffen.')
return self.respond(self.server.catalog.delete(data['id'],self.server.profiles,active)) return self.respond(self.server.catalog.delete(data['id'],self.server.profiles,active))
if self.path.endswith('/dismiss') and set(data)=={'id'}:return self.respond(self.server.catalog.dismiss(data['id'])) if self.path.endswith('/dismiss') and set(data)=={'id'}:return self.respond(self.server.catalog.dismiss(data['id']))
if self.path.endswith('/cancel') and not data:return self.respond(self.server.catalog.stop()) if self.path.endswith('/cancel') and not data:return self.respond(self.server.catalog.stop())
+12
View File
@@ -102,6 +102,18 @@ class EndpointTests(unittest.TestCase):
with patch('endpoint.relay',side_effect=forward): with patch('endpoint.relay',side_effect=forward):
code,body=self.request('/v1/models');self.assertEqual(code,207);self.assertEqual(body,{'ltx_native':'/v1/models'}) code,body=self.request('/v1/models');self.assertEqual(code,207);self.assertEqual(body,{'ltx_native':'/v1/models'})
self.ep.scheduler.gpu_mode='llm';self.ep.video=None self.ep.scheduler.gpu_mode='llm';self.ep.video=None
def test_comfy_browser_session_only_in_video_and_same_origin(self):
class NativeVideo:
def status(self):return {'service':{'ready':True}}
def relay(self,handler):handler.send({'native':True})
self.ep.video=NativeVideo();self.ep.scheduler.gpu_mode='video';self.ep.video_browser_auth=lambda h:h.get('Cookie')=='deck_session=synthetic'
for origin,expected in [(f'http://127.0.0.1:{self.port}',200),('https://foreign.example',403)]:
conn=http.client.HTTPConnection('127.0.0.1',self.port)
conn.request('GET','/',headers={'Cookie':'deck_session=synthetic','Origin':origin})
response=conn.getresponse();self.assertEqual(response.status,expected);response.read();conn.close()
conn=http.client.HTTPConnection('127.0.0.1',self.port);conn.request('GET','/',headers={'Cookie':'deck_session=wrong'})
response=conn.getresponse();self.assertEqual(response.status,401);response.read();conn.close()
self.ep.scheduler.gpu_mode='llm';self.ep.video=None
def test_video_api_redirect_is_explicit_not_translation(self): def test_video_api_redirect_is_explicit_not_translation(self):
code,body=self.request('/v1/videos',{'prompt':'synthetic'}) code,body=self.request('/v1/videos',{'prompt':'synthetic'})
self.assertEqual(code,410);self.assertEqual(body['error']['code'],'use_ltx_api') self.assertEqual(code,410);self.assertEqual(body['error']['code'],'use_ltx_api')
+38
View File
@@ -0,0 +1,38 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock
from video_comfy import VideoComfy
from profiles import LTX_REPO,LTX_FILE,LTX_COMPONENTS,Profiles
class VideoComfyTests(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name)
self.model=dict(id='a'*64,repo=LTX_REPO,file=LTX_FILE,kind='video',revision='1'*40)
self.entries=[self.model]+[dict(id=str(i)*64,repo=LTX_REPO,file=info['files'][0],kind='video',revision='1'*40) for i,info in enumerate(LTX_COMPONENTS.values(),2)]
self.catalog=Mock();self.catalog.status.side_effect=lambda:dict(entries=self.entries)
def entry(ident):
row=next((x for x in self.entries if x['id']==ident),None)
if not row:raise ValueError('missing')
return row
self.catalog.entry.side_effect=entry;self.profiles=Profiles(self.root/'profiles.json',self.catalog)
self.runtime=Mock();python=self.root/'python';python.touch();comfy=self.root/'comfy';(comfy/'comfy/ldm/lightricks').mkdir(parents=True)
self.runtime.paths.return_value=(python,comfy);self.runtime.status.return_value=dict(installed=True)
self.scheduler=Mock();self.scheduler.gpu_mode='llm'
helper=Mock();helper.call.return_value={'services':[]}
self.video=VideoComfy(self.scheduler,Mock(),helper,self.root/'video',self.catalog,self.profiles,self.runtime)
def tearDown(self):self.tmp.cleanup()
def test_ready_reuses_runtime_without_install(self):
row=self.video.models()[0];self.assertTrue(row['runnable']);self.assertEqual(len(row['components']),4)
self.video.select(self.model['id']);self.assertEqual(self.video.status()['selected'],self.model['id']);self.runtime.install.assert_not_called()
def test_missing_component_is_not_ready(self):
self.entries.pop();row=self.video.models()[0];self.assertFalse(row['runnable']);self.assertIn('Spatial',row['blockers'][0])
def test_wrong_revision_is_not_silently_used(self):
self.entries[1]['revision']='2'*40;self.assertFalse(self.video.models()[0]['runnable'])
def test_missing_runtime_and_model_cannot_start(self):
self.runtime.paths.return_value=(self.root/'missing',self.root/'missing-comfy')
self.video.select(self.model['id'])
with self.assertRaises(ValueError):self.video.switch('video')
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'])
+23
View File
@@ -0,0 +1,23 @@
import http.client
import threading
import unittest
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from video_comfy_proxy import relay
from test_video_proxy import Upstream
class Proxy(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def do_POST(self):relay(self,self.server.target)
def do_GET(self):relay(self,self.server.target)
class ComfyProxyTests(unittest.TestCase):
def test_binary_ranges_and_query_preserved_credentials_stripped(self):
upstream=ThreadingHTTPServer(('127.0.0.1',0),Upstream);proxy=ThreadingHTTPServer(('127.0.0.1',0),Proxy);proxy.target=upstream.server_port
for server in (upstream,proxy):threading.Thread(target=server.serve_forever,daemon=True).start()
try:
connection=http.client.HTTPConnection('127.0.0.1',proxy.server_port)
connection.request('POST','/prompt?native=1',body=b'synthetic',headers={'Authorization':'Bearer synthetic-secret','Cookie':'deck_session=synthetic','Content-Type':'application/json','Range':'bytes=0-22'})
response=connection.getresponse();self.assertEqual(response.status,206);self.assertEqual(response.read(),b'\x00original-LTX-response\xff');connection.close()
received=upstream.received;self.assertEqual(received[:2],('POST','/prompt?native=1'));self.assertEqual(received[3],b'synthetic');self.assertNotIn('Cookie',received[2]);self.assertNotIn('Authorization',received[2])
finally:
for server in (proxy,upstream):server.shutdown();server.server_close()
+12 -3
View File
@@ -3,13 +3,22 @@ window.VideoUI=(()=>{
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;} async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
function mode(root,s,profiles=[],port=null){ function mode(root,s,profiles=[],port=null){
if(root.dataset.busy==='1')return; if(root.dataset.busy==='1')return;
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: Original-API der Video-Laufzeit</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.service?.api_url?`<p>Interner Backend-Zugang: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a><div class="video-message" role="status"></div></section>`; root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: ComfyUI-API und Weboberfläche</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.service?.api_url?`<p>Backend: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>${s.service.ready&&port?`<a class="link" target="_blank" rel="noopener" href="${e(location.protocol+"//"+location.hostname+":"+port+"/")}">ComfyUI öffnen →</a>`:""}`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a><div class="video-message" role="status"></div></section>`;
root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next,profiles,port);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}}); root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next,profiles,port);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}});
} }
function services(root){ function services(root){
if(!root||root.dataset.bound)return;root.dataset.bound='1'; if(!root||root.dataset.bound)return;root.dataset.bound='1';
async function poll(){if(!root.isConnected)return;try{const s=await api('video');if(root.dataset.busy!=='1'){root.innerHTML=`<section class="card"><h2>Aktiver Videodienst</h2><p>Die Übersicht startet den hier gewählten Dienst. Jeder Dienst behält seine eigene API; keine Übersetzung von Generierungsparametern.</p>${s.services.map(x=>`<label><input type="radio" name="video-service" value="${e(x.id)}" ${x.id===s.selected?'checked':''} ${s.mode!=='llm'?'disabled':''}> ${e(x.name)} · ${e(x.api_url)}</label>`).join('')||'<p>Noch kein Videodienst durch den Administrator zugeordnet.</p>'}<p>Zum Wechseln zuerst in der Übersicht auf LLM schalten.</p><p class="video-message" role="status"></p></section>`;root.querySelectorAll('input').forEach(b=>b.onchange=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.value});root.querySelector('.video-message').textContent='Videodienst aktiviert. Start über die Übersicht.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});}}catch(x){root.textContent=x.message;}if(root.isConnected)setTimeout(poll,5000);}poll(); async function poll(){if(!root.isConnected)return;try{const s=await api('video');if(root.dataset.busy!=='1'){
root.innerHTML=`<section class="card"><h2>Gemeinsame ComfyUI-Laufzeit</h2><p>${s.runtime?.installed?'Vorhanden · wird mit Bildgenerierung geteilt':'Nicht installiert · unter Einstellungen → Bildgenerierung einrichten'}</p><p>5080: Diffusionsmodell und VAE. 3060: Textencoder. Große BF16-Dateien nutzen zusätzlich CPU-Auslagerung. Gewichte laden erst bei einer Workflow-Anfrage.</p></section>${(s.models||[]).map(x=>`<section class="card"><h3>${e(x.model.file)}</h3><strong>${e(x.label)}</strong><button class="secondary" data-components-model="${e(x.model.id)}">Komponenten prüfen / nachladen</button>${x.blockers.map(b=>`<p class="warning">${e(b)}</p>`).join('')}<p>${Object.keys(x.components).length} Komponenten geprüft · gleiche Quellversion</p><button data-select="${e(x.model.id)}" ${s.mode!=='llm'||x.model.id===s.selected?'disabled':''}>${x.model.id===s.selected?'Aktives Videomodell':'Als Videomodell aktivieren'}</button>${x.model.id===s.selected&&s.mode==='llm'?'<button data-select="">Auswahl aufheben</button>':''}</section>`).join('')||'<p>Kein unterstütztes LTX-Modell in der Bibliothek. Unter Entdecken herunterladen.</p>'}<p class="video-message" role="status"></p>`;
root.querySelectorAll('[data-components-model]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';try{const data=await api('profiles/components?'+new URLSearchParams({model_id:b.dataset.componentsModel}));root.innerHTML=`<section class="card"><h2>LTX-Komponenten</h2><p>${e(data.message)}</p>${data.requirements.map((r,i)=>`<p><strong>${e(r.label)}</strong> · ${r.available.length?'Bereits heruntergeladen':r.candidates.length?'Fehlt':'In dieser Quellversion nicht gefunden'}</p>${!r.available.length?r.candidates.map((c,j)=>`<button data-download-component="${i}:${j}">${e(c.name)} herunterladen</button>`).join(''):''}`).join('')}<p class="video-message"></p><button data-close-components>Zurück</button></section>`;root.querySelector('[data-close-components]').onclick=()=>{root.dataset.busy='0';poll();};root.querySelectorAll('[data-download-component]').forEach(d=>d.onclick=async()=>{const [i,j]=d.dataset.downloadComponent.split(':');const f=data.requirements[i].candidates[j];d.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:'video'});root.querySelector('.video-message').textContent='Download eingeplant. Fortschritt unter Downloads. Nach Abschluss werden die Komponenten automatisch erkannt.';}catch(x){root.querySelector('.video-message').textContent=x.message;d.disabled=false;}});}catch(x){root.dataset.busy='0';root.querySelector('.video-message').textContent=x.message;}});
root.querySelectorAll('[data-select]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.dataset.select});root.querySelector('.video-message').textContent='Videomodell ausgewählt. Start über die Übersicht.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});
}}catch(x){root.textContent=x.message;}if(root.isConnected)setTimeout(poll,5000);}poll();
}
function runtime(){
const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;
view.innerHTML='<section id="video-service-page"><h1>Video</h1><p>Die vorhandene ComfyUI-Laufzeit wird wiederverwendet. Deck prüft Modellgewichte und Komponenten; Generierungsparameter gehören in die ComfyUI-Oberfläche.</p><div class="studio-tabs"><button data-video-tab="runtime">Laufzeit / Aktives Modell</button><button data-video-tab="discover">Entdecken</button><button data-video-tab="library">Bibliothek</button><button data-video-tab="downloads">Downloads</button></div><div id="video-content"></div></section>';
function show(tab){const root=view.querySelector('#video-content');if(tab==='runtime'){root.innerHTML='<div id="video-services"></div>';services(root.firstChild);}else{root.innerHTML=CatalogUI.html(tab==='library',tab==='downloads');CatalogUI.bind('video',tab==='library',tab==='downloads');}}
view.querySelectorAll('[data-video-tab]').forEach(b=>b.onclick=()=>show(b.dataset.videoTab));show('runtime');
} }
function runtime(){const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;view.innerHTML='<section id="video-service-page"><h1>Video</h1><p>Video ist ein KI-Bereich in Deck. Die Übersicht startet die gewählte Video-Laufzeit. Im Videomodus stellt der gemeinsame API-Port deren Original-API bereit. Das separate LTX-Webfrontend gehört unter Weitere Dienste.</p><a href="#home">Zur Übersicht</a><div id="video-services"></div></section>';services(view.querySelector('#video-services'));}
return {mode,services,runtime}; return {mode,services,runtime};
})(); })();
+139
View File
@@ -0,0 +1,139 @@
"""Deck-owned LTX process, sharing the installed image ComfyUI environment."""
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import signal
import socket
import subprocess
import threading
import time
import urllib.request
from profiles import video_recipe
class VideoComfy:
def __init__(self,scheduler,stop_owned,helper,root,catalog,profiles,runtime):
self.scheduler=scheduler;self.stop_owned=stop_owned;self.helper=helper;self.root=Path(root);self.catalog=catalog;self.profiles=profiles;self.runtime=runtime
self.lock=threading.RLock();self.process=None;self.thread=None;self.port=None;self.state='idle';self.error=None;self.target_mode=None;self.switch_phase=None;self.switch_started_at=None
self.path=self.root/'comfy-selection.json';self.selection=json.loads(self.path.read_text()) if self.path.exists() else {}
self.foreign_blocked=False
try:self.foreign_blocked=any(x.get('running') is not False for x in self._foreign_services())
except (OSError,ValueError,KeyError,TypeError):self.foreign_blocked=True
if self.foreign_blocked:
self.scheduler.gpu_mode='video';self.state='failed';self.error='Fremder Videodienst läuft oder ist nicht prüfbar; GPU-Aufträge gesperrt.'
def _foreign_services(self):
path=Path(os.environ.get('DECK_DOCKER_HELPER_SOCKET','/run/athena-deck-docker/control.sock'))
if not path.is_socket() and not os.environ.get('DECK_DOCKER_HELPER_SOCKET'):return []
return self.helper.call('video-status')['services']
def models(self):
entries=self.catalog.status()['entries'];out=[]
for model in entries:
if not video_recipe(model):continue
components={};blockers=[]
try:self.catalog.entry(model['id'])
except ValueError as exc:blockers.append(str(exc))
for role,info in video_recipe(model).items():
item=next((x for x in entries if x['repo']==model['repo'] and x['revision']==model['revision'] and x['file'] in info['files']),None)
try:components[role]=self.profiles._component(model,role,item['id'] if item else None)['id']
except ValueError:blockers.append(info['label']+' fehlt oder ist unvollständig.')
python,comfy=self.runtime.paths()
if not python.is_file() or not (comfy/'comfy/ldm/lightricks').is_dir():blockers.append('Passende ComfyUI-LTX-Anbindung fehlt in der vorhandenen Laufzeit.')
out.append(dict(model=model,components=components,blockers=blockers,runnable=not blockers,state='ready' if not blockers else 'configured',label='Laufzeit bereit · lädt bei Anfrage' if not blockers else 'Voraussetzungen fehlen'))
return out
def select(self,ident):
with self.lock:
if self.scheduler.gpu_mode!='llm' or self.state=='switching':raise ValueError('Zuerst auf LLM wechseln.')
row=next((x for x in self.models() if x['model']['id']==ident),None)
if ident and not row:raise ValueError('Unterstütztes LTX-Modell aus der Bibliothek auswählen.')
self.root.mkdir(parents=True,exist_ok=True);self.selection={'model_id':ident or None};temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(self.selection));temp.replace(self.path)
return self.status()
def status(self):
with self.lock:
running=bool(self.process and self.process.poll() is None)
if self.process and not running and self.state!='switching':
self.state='failed';self.error='ComfyUI wurde unerwartet beendet. Auf LLM wechseln und Laufzeit erneut starten.'
rows=self.models();ident=self.selection.get('model_id');row=next((x for x in rows if x['model']['id']==ident),None)
service=dict(id=ident,name=row['model']['file'] if row else 'Kein Videomodell ausgewählt',running=running,ready=running and self.state=='ready',api_url='/ (ComfyUI auf dem gemeinsamen API-Port)',message='5080: Diffusion/VAE · 3060: Textencoder · große BF16-Gewichte nutzen CPU-Auslagerung.') if row else {}
return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status())
def switch(self,mode):
if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.')
with self.lock:
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
if mode=='video':
row=next((x for x in self.models() if x['model']['id']==self.selection.get('model_id')),None)
if not row:raise ValueError('Zuerst Videomodell aktivieren.')
if row['blockers']:raise ValueError(' '.join(row['blockers']))
self.state='switching';self.error=None;self.target_mode=mode;self.switch_phase='Deck-GPU-Aufträge beenden' if mode=='video' else 'Video entladen';self.switch_started_at=time.time()
with self.scheduler.cv:self.scheduler.gpu_mode='switching';self.scheduler.cv.notify_all()
self.thread=threading.Thread(target=self._switch,args=(mode,),daemon=True);self.thread.start()
return self.status()
def _switch(self,mode):
try:
if mode=='llm':
self._stop()
if any(x.get('running') is not False for x in self._foreign_services()):raise ValueError('Fremder Videodienst blockiert LLM-Modus.')
self.foreign_blocked=False
else:
foreign=self._foreign_services()
self.foreign_blocked=any(x.get('running') is not False for x in foreign)
if self.foreign_blocked:raise ValueError('Fremder Videodienst läuft oder ist nicht prüfbar. Er wird nicht von Deck beendet.')
self.stop_owned();deadline=time.monotonic()+60
with self.scheduler.cv:
while self.scheduler.active or self.scheduler.tts_active or self.scheduler.transition:
if time.monotonic()>deadline:raise ValueError('Deck-Aufträge noch aktiv.')
self.scheduler.cv.wait(.2)
self.scheduler.key=None
self.switch_phase='Freie GPUs prüfen';self._start()
with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all()
self.state='ready' if mode=='video' else 'idle'
except Exception as exc:
self._stop();self.error=str(exc);self.state='failed'
with self.scheduler.cv:self.scheduler.gpu_mode='video' if self.foreign_blocked else 'llm';self.scheduler.cv.notify_all()
def _start(self):
raw=subprocess.check_output(['nvidia-smi','--query-gpu=uuid,name,memory.used','--format=csv,noheader,nounits'],text=True)
gpus=[line.split(',') for line in raw.strip().splitlines()]
ordered=[next((g for g in gpus if name in g[1]),None) for name in ('5080','3060')]
if any(g is None for g in ordered):raise ValueError('RTX 5080 und RTX 3060 benötigt.')
if any(float(g[2])>256 for g in ordered):raise ValueError('GPUs sind nicht frei; fremde Prozesse werden nicht beendet.')
row=next(x for x in self.models() if x['model']['id']==self.selection['model_id'])
work=self.root/'comfy-work';work.mkdir(parents=True,exist_ok=True)
paths={'diffusion_models':row['model']['id'],'text_encoders':row['components']['text_encoder'],'vae':None,'latent_upscale_models':row['components']['spatial_upsampler']}
for key in paths:(work/'models'/key).mkdir(parents=True,exist_ok=True)
for folder,ident in [('diffusion_models',row['model']['id']),('text_encoders',row['components']['text_encoder']),('vae',row['components']['video_vae']),('vae',row['components']['audio_vae']),('latent_upscale_models',row['components']['spatial_upsampler'])]:
item=self.catalog.entry(ident);source=self.catalog.root/ident/('model'+PurePosixPath(item['file']).suffix);link=work/'models'/folder/PurePosixPath(item['file']).name
for old in link.parent.iterdir():
if old.is_symlink() and old.name!=link.name and folder!='vae':old.unlink()
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))
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))
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)
deadline=time.monotonic()+180
while time.monotonic()<deadline:
if self.process.poll() is not None:raise ValueError('ComfyUI-Start fehlgeschlagen; keine Gewichte geladen.')
try:
with urllib.request.urlopen(f'http://127.0.0.1:{self.port}/object_info',timeout=2) as response:info=json.load(response)
required={'DeckLTXTextEncoderLoader','LTXVEmptyLatentAudio','LTXVConcatAVLatent','LTXVAudioVAEDecode'}
if not required<=info.keys():raise ValueError('ComfyUI fehlen erforderliche LTX-Nodes.')
return
except (OSError,TimeoutError):time.sleep(.5)
raise ValueError('ComfyUI ist noch nicht bereit.')
def _stop(self):
process=self.process
if process and process.poll() is None:
os.killpg(process.pid,signal.SIGTERM)
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 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.')
relay(handler,self.port)
def close(self):
if self.thread and self.thread.is_alive():self.thread.join(190)
self._stop()
+15
View File
@@ -0,0 +1,15 @@
"""Strict routing for the Deck LTX workflow's text encoder."""
import torch
import comfy.sd
import folder_paths
class DeckLTXTextEncoderLoader:
@classmethod
def INPUT_TYPES(cls):return {'required':{'clip_name':(folder_paths.get_filename_list('text_encoders'),)}}
RETURN_TYPES=('CLIP',);FUNCTION='load';CATEGORY='Deck / LTX'
def load(self,clip_name):
if torch.cuda.device_count()!=2:raise RuntimeError('Deck requires its reserved 5080 and 3060.')
clip=comfy.sd.load_clip(ckpt_paths=[folder_paths.get_full_path_or_raise('text_encoders',clip_name)],embedding_directory=folder_paths.get_folder_paths('embeddings'),clip_type=comfy.sd.CLIPType.LTXV,model_options={'load_device':torch.device('cuda:1'),'offload_device':torch.device('cpu'),'initial_device':torch.device('cpu')})
if clip.patcher.load_device!=torch.device('cuda:1'):raise RuntimeError('LTX text encoder GPU assignment failed.')
return (clip,)
NODE_CLASS_MAPPINGS={'DeckLTXTextEncoderLoader':DeckLTXTextEncoderLoader}
NODE_DISPLAY_NAME_MAPPINGS={'DeckLTXTextEncoderLoader':'Deck · LTX Textencoder auf RTX 3060'}
+47
View File
@@ -0,0 +1,47 @@
"""Direct authenticated transport to Deck's own ComfyUI, including WebSocket."""
import http.client
import socket
import select
from video_proxy import MAX_BODY,REQUEST_HEADERS,HOP_HEADERS
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}']
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)
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()