From fe296cebb1b099a98e5f9c8eda05b7d1480d6bb9 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:30:08 +0200 Subject: [PATCH] Reuse shared ComfyUI runtime for Deck-owned LTX video --- README.md | 26 +++---- catalog-ui.js | 4 +- deploy/Dockerfile | 2 +- deploy/Dockerfile.app-update | 15 ++++ deploy/install.py | 17 +++-- endpoint.py | 8 +- execution_setup.py | 2 +- profiles.py | 2 +- server.py | 19 ++++- test_endpoint.py | 12 +++ test_video_comfy.py | 38 ++++++++++ test_video_comfy_proxy.py | 23 ++++++ video-ui.js | 15 +++- video_comfy.py | 139 +++++++++++++++++++++++++++++++++++ video_comfy_node.py | 15 ++++ video_comfy_proxy.py | 47 ++++++++++++ 16 files changed, 350 insertions(+), 34 deletions(-) create mode 100644 deploy/Dockerfile.app-update create mode 100644 test_video_comfy.py create mode 100644 test_video_comfy_proxy.py create mode 100644 video_comfy.py create mode 100644 video_comfy_node.py create mode 100644 video_comfy_proxy.py diff --git a/README.md b/README.md index ec2f7aa..ba32640 100644 --- a/README.md +++ b/README.md @@ -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. -### 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 -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: ""}` 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. - -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. +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. diff --git a/catalog-ui.js b/catalog-ui.js index ec1d696..ca98eed 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -30,7 +30,7 @@ window.CatalogUI = (() => { root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); } function renderLibrary() { - el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `
${escape(x.role_label)} · HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${executionHTML(x.execution)}${x.used_by_profiles?.length?`

Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.

`:""}
${x.profile_eligible===true?``:""}
`).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; + el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `
${escape(x.role_label)} · HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${executionHTML(x.execution)}${x.used_by_profiles?.length?`

Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.

`:""}
${x.profile_eligible===true?``:""}
`).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; 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() { @@ -44,7 +44,7 @@ window.CatalogUI = (() => { } async function refresh() { 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); } finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); } } diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 4b0d9e5..0e51640 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -14,7 +14,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins 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 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 network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ diff --git a/deploy/Dockerfile.app-update b/deploy/Dockerfile.app-update new file mode 100644 index 0000000..9c58085 --- /dev/null +++ b/deploy/Dockerfile.app-update @@ -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"] diff --git a/deploy/install.py b/deploy/install.py index 4623828..d0f46fd 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent 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): @@ -64,13 +64,15 @@ def free_port(port): raise RuntimeError('Der gewünschte Loopback-Port ist bereits belegt.') from None -def build(): +def build(runtime_image=None): digest = hashlib.sha256() for name in FILES: digest.update(name.encode());digest.update((ROOT/name).read_bytes()) tag = 'athena-deck-standalone:'+digest.hexdigest()[:16] 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 @@ -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.') -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) previous=owned(config['name'],base) 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) 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) - 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 image==config['image'] and selected_ports==config.get('api_ports',[]) and not force: print('Dieser Quellstand ist bereits installiert.');return backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') 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'] if was_running:run('docker','stop','--time','15',config['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('--name',default='athena-deck-standalone') 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('--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() @@ -270,7 +273,7 @@ def main(): if item:run('docker','start',config['name']) else:launch(config) 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__': try:main() diff --git a/endpoint.py b/endpoint.py index 65b000b..31a6a7d 100644 --- a/endpoint.py +++ b/endpoint.py @@ -188,8 +188,10 @@ class APIHandler(BaseHTTPRequestHandler): def route(self): ep=self.server.endpoint;admitted=False try: - if not ep.authenticate(self.headers.get('Authorization','')):raise APIError('Gültiger API-Bearer-Token erforderlich.',401,'invalid_api_key') - if self.headers.get('Origin'):raise APIError('Browserzugriff erfolgt über die Deck-Verwaltung; keine Cross-Origin-API-Freigabe.',403) + browser=bool(ep.video and ep.scheduler.gpu_mode=='video' and getattr(ep,'video_browser_auth',lambda h:False)(self.headers)) + if not browser and not ep.authenticate(self.headers.get('Authorization','')):raise APIError('Gültiger API-Bearer-Token oder Deck-Anmeldung erforderlich.',401,'invalid_api_key') + 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: if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping') 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') video=ep.video.status() 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 return model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt'} diff --git a/execution_setup.py b/execution_setup.py index 5608f7c..cb06e21 100644 --- a/execution_setup.py +++ b/execution_setup.py @@ -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)')) if video_recipe(model): 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 result['installed']=bool(installed.get(kind)) if kind!='chat' and result['state']!='workflow_missing': diff --git a/profiles.py b/profiles.py index a02ebf9..46967cd 100644 --- a/profiles.py +++ b/profiles.py @@ -205,7 +205,7 @@ class Profiles: 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] 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(), 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): diff --git a/server.py b/server.py index 89838cf..1e21b64 100644 --- a/server.py +++ b/server.py @@ -113,8 +113,10 @@ class Server(ThreadingHTTPServer): self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler) 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.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.profiles.video_runtime_ready=lambda:self.image_runtime.status().get("installed",False) from dashboard_data import Dashboard self.dashboard=Dashboard(self,self.catalog.root.parent) if self.endpoint.config['autostart']: @@ -124,6 +126,16 @@ class Server(ThreadingHTTPServer): self.sessions = {} self.login_attempts = [] 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): def log_message(self, *args): @@ -262,7 +274,7 @@ class Handler(BaseHTTPRequestHandler): return self.headers.get('Host') in allowed 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) def do_GET(self): @@ -545,6 +557,9 @@ class Handler(BaseHTTPRequestHandler): if set(data)!={'id'}:raise ValueError('Modelldatei-ID erwartet.') with self.server.worker.lock: 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)) 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()) diff --git a/test_endpoint.py b/test_endpoint.py index c630335..2873c4d 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -102,6 +102,18 @@ class EndpointTests(unittest.TestCase): with patch('endpoint.relay',side_effect=forward): 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 + 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): code,body=self.request('/v1/videos',{'prompt':'synthetic'}) self.assertEqual(code,410);self.assertEqual(body['error']['code'],'use_ltx_api') diff --git a/test_video_comfy.py b/test_video_comfy.py new file mode 100644 index 0000000..c73ce21 --- /dev/null +++ b/test_video_comfy.py @@ -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']) diff --git a/test_video_comfy_proxy.py b/test_video_comfy_proxy.py new file mode 100644 index 0000000..b1ff26d --- /dev/null +++ b/test_video_comfy_proxy.py @@ -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() diff --git a/video-ui.js b/video-ui.js index b4b0ec1..def2851 100644 --- a/video-ui.js +++ b/video-ui.js @@ -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;} function mode(root,s,profiles=[],port=null){ if(root.dataset.busy==='1')return; - root.innerHTML=`

GPU-Modus

Gemeinsamer API-Port: ${e(port||"siehe Endpunkt")} · LLM-Modus: OpenAI-API · Video-Modus: Original-API der Video-Laufzeit

${e(s.service?.name||'Kein Videodienst eingerichtet')}

${s.state==='switching'?`
${e(s.switch_phase)}
`:`

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

`}${s.service?.api_url?`

Interner Backend-Zugang: ${e(s.service.api_url)}

${e(s.service.message)}

`:''}${s.error?``:''}

Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.

Aktiven Videodienst im Bereich Video wählen
`; + root.innerHTML=`

GPU-Modus

Gemeinsamer API-Port: ${e(port||"siehe Endpunkt")} · LLM-Modus: OpenAI-API · Video-Modus: ComfyUI-API und Weboberfläche

${e(s.service?.name||'Kein Videodienst eingerichtet')}

${s.state==='switching'?`
${e(s.switch_phase)}
`:`

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

`}${s.service?.api_url?`

Backend: ${e(s.service.api_url)}

${e(s.service.message)}

${s.service.ready&&port?`ComfyUI öffnen →`:""}`:''}${s.error?``:''}

Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.

Aktiven Videodienst im Bereich Video wählen
`; 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){ 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=`

Aktiver Videodienst

Die Übersicht startet den hier gewählten Dienst. Jeder Dienst behält seine eigene API; keine Übersetzung von Generierungsparametern.

${s.services.map(x=>``).join('')||'

Noch kein Videodienst durch den Administrator zugeordnet.

'}

Zum Wechseln zuerst in der Übersicht auf LLM schalten.

`;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=`

Gemeinsame ComfyUI-Laufzeit

${s.runtime?.installed?'Vorhanden · wird mit Bildgenerierung geteilt':'Nicht installiert · unter Einstellungen → Bildgenerierung einrichten'}

5080: Diffusionsmodell und VAE. 3060: Textencoder. Große BF16-Dateien nutzen zusätzlich CPU-Auslagerung. Gewichte laden erst bei einer Workflow-Anfrage.

${(s.models||[]).map(x=>`

${e(x.model.file)}

${e(x.label)}${x.blockers.map(b=>`

${e(b)}

`).join('')}

${Object.keys(x.components).length} Komponenten geprüft · gleiche Quellversion

${x.model.id===s.selected&&s.mode==='llm'?'':''}
`).join('')||'

Kein unterstütztes LTX-Modell in der Bibliothek. Unter Entdecken herunterladen.

'}

`; + 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=`

LTX-Komponenten

${e(data.message)}

${data.requirements.map((r,i)=>`

${e(r.label)} · ${r.available.length?'Bereits heruntergeladen':r.candidates.length?'Fehlt':'In dieser Quellversion nicht gefunden'}

${!r.available.length?r.candidates.map((c,j)=>``).join(''):''}`).join('')}

`;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='

Video

Die vorhandene ComfyUI-Laufzeit wird wiederverwendet. Deck prüft Modellgewichte und Komponenten; Generierungsparameter gehören in die ComfyUI-Oberfläche.

'; + function show(tab){const root=view.querySelector('#video-content');if(tab==='runtime'){root.innerHTML='
';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='

Video

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.

Zur Übersicht
';services(view.querySelector('#video-services'));} return {mode,services,runtime}; })(); diff --git a/video_comfy.py b/video_comfy.py new file mode 100644 index 0000000..6752551 --- /dev/null +++ b/video_comfy.py @@ -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()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()