Reuse shared ComfyUI runtime for Deck-owned LTX video
This commit is contained in:
@@ -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
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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'])
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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'}
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user