From badc7701fa7a3b3c26dc9d0193e911aaa472e563 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:24:52 +0200
Subject: [PATCH] Replace Deck video generation with selected external service
lifecycle
---
README.md | 39 ++----
app.js | 2 +-
deploy/Dockerfile | 3 +-
deploy/docker_helper.py | 48 ++++++-
deploy/install.py | 2 +-
deploy/video-requirements.lock | 63 ---------
docker_support.py | 4 +-
docs/DOCKER_SERVICES.md | 24 ++++
endpoint.py | 43 +------
index.html | 2 +-
network/install_remote.py | 2 +-
profiles.py | 3 +-
server.py | 42 ++----
studio.js | 7 +-
test_docker_support.py | 11 ++
test_endpoint.py | 28 ++--
test_video.py | 81 ++++--------
test_video_components.py | 2 +-
test_video_devices.py | 39 ------
video-ui.js | 77 ++---------
video.py | 227 ++++++++-------------------------
video_runtime.py | 50 --------
video_worker.py | 56 --------
23 files changed, 219 insertions(+), 636 deletions(-)
delete mode 100644 deploy/video-requirements.lock
delete mode 100644 test_video_devices.py
delete mode 100644 video_runtime.py
delete mode 100644 video_worker.py
diff --git a/README.md b/README.md
index 3a42b96..714cd02 100644
--- a/README.md
+++ b/README.md
@@ -149,39 +149,20 @@ 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.
-### LTX-2.5-Komponentenrezept
+### Video: externe Dienste statt Deck-Profile
-Video → Profile → Komponenten unterstützt das offizielle `Lightricks/LTX-2.5`-Modell `diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors`. Das Herstellerrezept für die zweistufige Distilled-Pipeline mit fester Bildanzahl umfasst Gemma 4 12B inklusive LTX-Projektionen, Video-VAE, Audio-VAE/Vocoder und Spatial-Upsampler. Quelle: https://huggingface.co/Lightricks/LTX-2.5 . Metadaten und Downloads sind an den Commit des Hauptmodells gebunden; Zuordnung anderer Revisionen wird abgelehnt. Der Katalog kann dafür feste historische Commits abrufen. Vorhandene Komponenten werden erkannt, fehlende über die normale Warteschlange geladen und anschließend im Profil gespeichert.
+Deck verwaltet für Video ausschließlich den aktiven Dienst, Start/Stopp und die GPU-Freigabe. Unter **Weitere Dienste → 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.
-Das Rezept unterstützt keine beliebigen Quantisierungen, Comfy-INT8-Encoder oder andere Modellfamilien. Duration-Head, Temporal-Upsampling und DFR sind optional und nicht enthalten. Die Video-Ausführung bleibt gesperrt, solange kein Video-Worker angebunden ist. Vollständige Dateien sind keine Zusage, dass BF16-Modell und Encoder in RAM/VRAM passen. Komponenten werden nicht als eigenständige Modelle angeboten. Keine automatischen Zusatzdownloads beim Öffnen der Ansicht.
+Videoprofile, Testformular, eigene Video-Laufzeitinstallation und `/v1/videos`-Generierung wurden 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.
-### Exklusiver Video-Modus und Worker
+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).
-Unter **Video → Profile** genau ein Profil mit **Am API-Endpunkt freigegeben** auswählen, dann in der Übersicht **Video** drücken. Ein neuer Haken ersetzt die vorige Video-Freigabe; Abwählen entfernt sie. Die Übersicht zeigt nur den Profilnamen und die Modusschalter. Änderungen der Video-Freigabe erfordern den LLM-Modus. Deck schließt zuerst die GPU-Auftragsannahme, beendet eigene Chat-/Auto-Test-/Bild-/TTS-Arbeit und entlädt seinen llama.cpp-Prozess. Erst nach Freigabe der Reservierungen und Prüfung **aller** sichtbaren GPUs startet der eigene LTX-Worker. Fremde GPU-Prozesse werden niemals beendet; der Wechsel scheitert dann mit einer sichtbaren Meldung. **LLM** beendet den Video-Prozess einschließlich einer laufenden Generierung und öffnet die GPU-Auftragsannahme wieder. Das nächste Chat-Modell lädt bei Anfrage. Nach Deck-Neustart gilt LLM; es gibt keinen automatischen Videostart.
+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. Während Video aktiv ist, sind Chat/Bild/TTS-GPU-Aufträge gesperrt. CPU-STT bleibt verfügbar. 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.
-Die Video-Laufzeit lässt sich unter Einstellungen → Laufzeiten → Video installieren/abbrechen. Sie verwendet eine eigene venv, den in `video_runtime.py` gepinnten offiziellen LTX-Commit sowie `deploy/video-requirements.lock` (PyTorch CUDA 12.8, keine Host-Treiberänderung). Die Module und Lockdatei gehören zu beiden Installationspaketen. Der erste Adapter unterstützt das dokumentierte LTX-2.5-Distilled-BF16-Komponentenrezept. Beide GPUs sind für Deck exklusiv reserviert. Standardmäßig erfolgt die Berechnung auf der RTX 5080 mit Disk-Streaming; im Videoprofil kann der Textencoder separat der RTX 3060 zugeordnet werden. „Bereit“ heißt: persistenter Worker, Pipeline und Komponentenmetadaten vorbereitet. BF16-Gewichte werden bedarfsgerecht gestreamt, nicht vollständig im VRAM gehalten. Eine freie GPU garantiert keinen OOM-freien Auftrag.
+LTX auf Athena: originale API am Host `http://127.0.0.1:41955`, bestehender SSH-Zugang über LTX Athena. Alternativer Tunnel vom Mac:
-Eigene Video-API auf demselben API-Port und mit demselben Bearer-Token wie Chat (keine vollständige OpenAI-Videos-Kompatibilitätszusage):
+```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
+```
-- `GET /v1/videos/models`: `athena-video`, wenn das ausgewählte Profil ausführbar ist.
-- `POST /v1/videos`: `{model:"athena-video",prompt:"…",width:512,height:320,frames:9,fps:24,seed:42}` → HTTP 202 mit Auftrags-ID. Der Video-Modus muss bereits bereit sein. Ein Auftrag gleichzeitig, kein automatischer Moduswechsel.
-- `GET /v1/videos/{id}`: Zustand/Phase und Fehler des letzten Auftrags, ohne Prompt.
-- `GET /v1/videos/{id}/content`: fertige MP4 einschließlich Audiospur.
-
-Anfragewerte überschreiben Profilstandards. Breite/Höhe sind Vielfache von 64, 256–1920 bzw. 256–1088; Bildanzahl 9–241 in der Form 8n+1, FPS 1–60. Distilled hat eine feste Schrittfolge; das bisherige allgemeine Schritte-Profilfeld wird von diesem Adapter nicht verwendet. Resultate liegen im Deck-Zustandsverzeichnis `video/`; der erste Adapter bietet jeweils den letzten Auftrag an. Kein Prompt wird auf Platte geschrieben. Eigener Video-Test mit Vorschau/Download unter Video → Testen. Bild-/Audio-Upload, automatische Video-Tool-Aufträge aus Hermes, Abfragehistorie und automatische Ergebnisbereinigung sind noch nicht umgesetzt.
-
-Während Video/Moduswechsel erhalten neue Chat-, Bild- und TTS-API-Aufträge HTTP 503 mit `video_mode_active`. CPU-STT bleibt verfügbar. Ein Wechsel wird nur über die angemeldete Verwaltungsoberfläche ausgelöst; API-Clients dürfen den Modus nicht heimlich zurückschalten. Die interne Verwaltungs-API verwendet `GET /api/v1/video`, `POST /api/v1/video/profile` mit `{id}`, `POST /api/v1/video/mode` mit `{mode:"llm"|"video"}`, `/api/v1/video/generate` und `/api/v1/video-runtime/install|cancel`; jeweils bestehender Sitzungsschutz und POST-Header `X-Athena-Deck: 1`.
-
-Video-Build-Voraussetzung: Python-Entwicklungsheader passend zur verwendeten Python-Version sowie ein C-Compiler (Debian: `python3-dev`, `build-essential`). Das Docker-Installationspaket bringt diese mit. Triton kompiliert seinen CUDA-Helfer beim ersten Auftrag; dafür werden keine Host-Treiber installiert.
-
-Verifiziert auf Athena am 29.09.2026: offizielles LTX-2.5-Distilled-BF16-Profil mit zugeordneten Komponenten vorbereitet; exklusiver Modus sperrt Chat mit HTTP 503 / `video_mode_active`; synthetischer POST-Videoauftrag liefert HTTP 202, Statusabfrage und MP4-Download funktionieren. Ergebnis: 256×256, 9 dekodierbare Bilder, 24 FPS und Audiospur (12.802 Bytes). Nach Rückwechsel auf LLM war der Video-Prozess beendet und der GPU-Speicher wieder auf Treibergrundbelegung (3060: 1 MiB, 5080: 6 MiB). Der anfängliche Triton-Kompilierfehler wurde durch Python-Entwicklungsheader im Deck-Image behoben. Kein Produktivdienst wurde für diese Prüfung verändert. Dieser kleine Funktionstest ist keine Speicher- oder Geschwindigkeitsgarantie für größere Auflösungen/Längen.
-
-Video-Oberfläche: Moduswechsel erfolgen ausschließlich in der Übersicht. Eine laufende Ladeanzeige zeigt Phase und verstrichene Zeit. Video → Testen meldet einen falschen Modus mit Link zur Übersicht und bestätigt das Absenden sofort. Video → Laufend zeigt ausschließlich Worker, aktuellen Auftrag und Ergebnis. Installationsaktionen liegen unter Einstellungen → Laufzeiten → Video. Die Auftragsanzeige wird alle 1,5 Sekunden aktualisiert; der Worker liefert Phasen, jedoch keine belastbaren Prozentwerte.
-
-### Video: Geräte pro Komponente
-
-Unter **Video → Profile → Bearbeiten → Komponenten und Geräte** die GPU für Videopipeline und Textencoder auswählen. Gespeichert werden stabile GPU-UUIDs (`video_device`, `text_encoder_device`), keine flüchtigen CUDA-Indizes. Alte Profile nutzen weiterhin automatisch bevorzugt die RTX 5080 und denselben Ort für den Textencoder. Auswahl einer nicht vorhandenen GPU führt beim Laden zu einem klaren Fehler statt stiller Umleitung. Änderungen werden erst nach Rückwechsel auf LLM und erneutem Wechsel auf Video übernommen.
-
-Der gepinnte LTX-Adapter unterstützt einen separaten CUDA-PromptEncoder mit Disk-Streaming. Seine Video-/Audio-Embeddings und Attention-Maske werden vor der Diffusion auf die Pipeline-GPU übertragen. Video-VAE, Audio-VAE und Spatial-Upsampler bleiben an die Videopipeline gebunden. CPU-Ausführung sowie unabhängige Decoder-/Upsampler-Geräte sind derzeit ausdrücklich nicht freigeschaltet. Fremde GPU-Prozesse bleiben geschützt; Video bleibt exklusiv.
-
-Speicherhinweise im Editor vergleichen die tatsächliche Dateigröße mit GPU-Kapazität und verfügbarer VRAM-Momentaufnahme. Sie sind keine Spitzenbedarfsmessung und keine Passgarantie. BF16-Dateien oberhalb der Kapazität benötigen Streaming; Auflösung, Bildanzahl und Aktivierungen verursachen weiteren Bedarf. Eine zweite GPU beschleunigt die sequenzielle Pipeline nicht automatisch.
+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.
diff --git a/app.js b/app.js
index 396e7dc..57cc07f 100644
--- a/app.js
+++ b/app.js
@@ -8,7 +8,7 @@ const metric=(title,value)=>`
${title}${value}v==null?'':``;
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
-if(page==='video-runtime'){VideoUI.runtime();return;}
+if(['video','video-runtime'].includes(page)){VideoUI.runtime();return;}
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
if(page==='access'){await accessPage();return;}
if(page==='network'){await networkPage();return;}
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 6276027..0c7099b 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -13,9 +13,8 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
-COPY deploy/video-requirements.lock /app/deploy/video-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
-COPY 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 profiles.py capacity.py server.py runtime.py video.py video_worker.py video_runtime.py catalog.py hub_auth.py auth.py collect_hardware.py /app/
+COPY 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 profiles.py capacity.py server.py runtime.py video.py catalog.py hub_auth.py auth.py collect_hardware.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 index.html app.js studio.js runtime-ui.js catalog-ui.js style.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/docker_helper.py b/deploy/docker_helper.py
index d15350a..fd97e64 100644
--- a/deploy/docker_helper.py
+++ b/deploy/docker_helper.py
@@ -1,6 +1,9 @@
#!/usr/bin/env python3
"""Root-owned local helper. Only inventory and fresh Docker install; no generic RPC."""
import argparse
+import re
+import urllib.request
+import urllib.error
import json
import os
from pathlib import Path
@@ -97,8 +100,51 @@ class Manager:
self._checked(['/usr/bin/docker','compose','version','--short'])
self._phase('Docker bereit. Keine Anwendungen oder Modelle installiert.','complete')
except Exception as exc:self._phase(str(exc) if isinstance(exc,ValueError) else 'Docker-Installation fehlgeschlagen. Hostzustand vor erneutem Versuch prüfen.','failed')
+ def video_configs(self):
+ path=self.state/'video-services.json'
+ if not path.exists():return []
+ if path.is_symlink() or path.stat().st_uid!=0 or path.stat().st_mode&0o022:raise ValueError('Videodienste müssen durch root eingerichtet werden.')
+ configs=json.loads(path.read_text())
+ if not isinstance(configs,list) or len(configs)>20:raise ValueError('Ungültige Dienstliste.')
+ ids=set()
+ for config in configs:
+ if not re.fullmatch('[a-z0-9-]{1,40}',config.get('id','')) or config['id'] in ids or not re.fullmatch('[a-f0-9]{64}',config.get('container_id','')):raise ValueError('Ungültige Dienstzuordnung.')
+ ids.add(config['id'])
+ return configs
+ def video_status(self,config):
+ result=run(['/usr/bin/docker','inspect','--format','{{.State.Running}}',config['container_id']])
+ if result.returncode:return dict(id=config['id'],name=config['name'],running=None,ready=False,api_url=config['api_url'],message='Zugeordneter Container fehlt.')
+ running=result.stdout.strip()=='true';ready=False
+ if running:
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self,*args,**kwargs):return None
+ try:
+ headers={}
+ if config.get('token_file'):headers['Authorization']='Bearer '+Path(config['token_file']).read_text().strip()
+ request=urllib.request.Request(config['health_url'],headers=headers)
+ with urllib.request.build_opener(NoRedirect).open(request,timeout=2) as response:ready=response.status==200
+ except (OSError,ValueError,urllib.error.URLError):pass
+ return dict(id=config['id'],name=config['name'],running=running,ready=ready,api_url=config['api_url'],message=config.get('message','Original-API des Dienstes.'))
+ def video_inventory(self):return {'services':[self.video_status(config) for config in self.video_configs()]}
+ def video_action(self,ident,start):
+ with self.lock:
+ configs=self.video_configs();config=next((c for c in configs if c['id']==ident),None)
+ if config is None:raise ValueError('Videodienst nicht freigegeben.')
+ status=self.video_status(config)
+ if start and status['running']:return status
+ if start:
+ if any(self.video_status(c)['running'] is not False for c in configs if c['id']!=ident):raise ValueError('Ein anderer Videodienst ist noch aktiv oder nicht prüfbar.')
+ probe=run(['/usr/bin/nvidia-smi','--query-compute-apps=pid','--format=csv,noheader,nounits'])
+ if probe.returncode or probe.stdout.strip():raise ValueError('GPU durch einen anderen Dienst belegt. Deck beendet keine fremden Modelle. Alten Router zuerst freigeben.')
+ result=run(['/usr/bin/docker','start',config['container_id']] if start else ['/usr/bin/docker','stop','--time','30',config['container_id']],timeout=45)
+ if result.returncode:raise ValueError('Videodienst konnte nicht gestartet oder beendet werden.')
+ return self.video_status(config)
def dispatch(self,data):
- if not isinstance(data,dict) or set(data)!={'action'}:raise ValueError('Ungültige Helferanfrage.')
+ if not isinstance(data,dict):raise ValueError('Ungültige Helferanfrage.')
+ if set(data)=={'action','service'} and data['action'] in ('video-start','video-stop'):
+ return self.video_action(data['service'],data['action']=='video-start')
+ if set(data)!={'action'}:raise ValueError('Ungültige Helferanfrage.')
+ if data['action']=='video-status':return self.video_inventory()
if data['action']=='status':return self.status()
if data['action']=='install':return self.install()
raise ValueError('Aktion nicht erlaubt.')
diff --git a/deploy/install.py b/deploy/install.py
index 2d01c76..3aa487a 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 = ['deploy/video-requirements.lock','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_worker.py','video_runtime.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.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/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-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.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):
diff --git a/deploy/video-requirements.lock b/deploy/video-requirements.lock
deleted file mode 100644
index 86b404d..0000000
--- a/deploy/video-requirements.lock
+++ /dev/null
@@ -1,63 +0,0 @@
-accelerate==1.15.0
-annotated-doc==0.0.5
-anyio==4.15.1
-av==18.1.0
-certifi==2026.7.22
-click==8.5.0
-cloudpickle==3.1.2
-cuda-bindings==12.9.7
-cuda-pathfinder==1.6.0
-cuda-toolkit==12.8.1
-einops==0.8.2
-filelock==3.32.3
-fsspec==2026.7.0
-h11==0.16.0
-hf-xet==1.6.0
-httpcore==1.0.9
-httpx==0.28.1
-huggingface_hub==1.33.0
-idna==3.20
-Jinja2==3.1.6
-markdown-it-py==4.2.0
-MarkupSafe==3.0.3
-mdurl==0.1.2
-mpmath==1.3.0
-networkx==3.6.1
-numpy==2.5.2
-nvidia-cublas-cu12==12.8.4.1
-nvidia-cuda-cupti-cu12==12.8.90
-nvidia-cuda-nvrtc-cu12==12.8.93
-nvidia-cuda-runtime-cu12==12.8.90
-nvidia-cudnn-cu12==9.19.0.56
-nvidia-cufft-cu12==11.3.3.83
-nvidia-cufile-cu12==1.13.1.3
-nvidia-curand-cu12==10.3.9.90
-nvidia-cusolver-cu12==11.7.3.90
-nvidia-cusparse-cu12==12.5.8.93
-nvidia-cusparselt-cu12==0.7.1
-nvidia-nccl-cu12==2.28.9
-nvidia-nvjitlink-cu12==12.8.93
-nvidia-nvshmem-cu12==3.4.5
-nvidia-nvtx-cu12==12.8.90
-OpenImageIO==3.1.17.0
-packaging==26.3
-pillow==12.3.0
-psutil==7.2.2
-Pygments==2.21.0
-PyYAML==6.0.3
-regex==2026.9.29
-rich==15.0.0
-safetensors==0.8.0
-scipy==1.18.1
-setuptools==78.1.0
-shellingham==1.5.4
-sympy==1.14.0
-tokenizers==0.22.2
-torch==2.11.0+cu128
-torchaudio==2.11.0+cu128
-torchvision==0.26.0+cu128
-tqdm==4.70.1
-transformers==5.14.1
-triton==3.6.0
-typer==0.27.2
-typing_extensions==4.16.0
diff --git a/docker_support.py b/docker_support.py
index 6ee9e61..ea93611 100644
--- a/docker_support.py
+++ b/docker_support.py
@@ -7,10 +7,10 @@ MANAGED_LABEL='io.athena-deck.managed=true'
ROLE_LABEL='io.athena-deck.role=application'
class DockerSupport:
- def call(self,action):
+ def call(self,action,service=None):
path=os.environ.get('DECK_DOCKER_HELPER_SOCKET','/run/athena-deck-docker/control.sock')
with socket.socket(socket.AF_UNIX) as sock:
- sock.settimeout(20);sock.connect(path);sock.sendall(json.dumps({'action':action}).encode()+b'\n')
+ sock.settimeout(65);sock.connect(path);sock.sendall(json.dumps({'action':action,**({'service':service} if service is not None else {})}).encode()+b'\n')
with sock.makefile('rb') as stream:raw=stream.readline(1048577)
if len(raw)>1048576:raise ValueError('Docker-Helferantwort zu groß.')
value=json.loads(raw)
diff --git a/docs/DOCKER_SERVICES.md b/docs/DOCKER_SERVICES.md
index 0f722bc..941f8c9 100644
--- a/docs/DOCKER_SERVICES.md
+++ b/docs/DOCKER_SERVICES.md
@@ -77,3 +77,27 @@ einem frischen Debian-Host steht noch aus; auf Athena wird dafür nichts ersetzt
API: `GET /api/v1/docker`, `POST /api/v1/docker/install` mit `{"confirm": true}`.
Referenz: https://docs.docker.com/engine/install/debian/
+
+## Videodienste
+
+Zusätzlich zum labelgefilterten Inventar kann der root-eigene Helfer explizit registrierte Videodienste starten und stoppen. Registrierung erfolgt einmalig in `/var/lib/athena-deck-docker/video-services.json` (root-eigen, 0600). Beispiel ohne Zugangsdaten:
+
+```json
+[
+ {
+ "id": "ltx",
+ "name": "LTX Desktop",
+ "container_id": "VOLLSTAENDIGE_64_STELLIGE_DOCKER_ID",
+ "api_url": "http://127.0.0.1:41955",
+ "health_url": "http://127.0.0.1:41955/health",
+ "token_file": "/etc/mike-ai/secrets/ltx-remote-token",
+ "message": "Originale LTX-API über den vorhandenen LTX-Athena-SSH-Tunnel."
+ }
+]
+```
+
+Keine Secrets werden an Deck übertragen. Der Helfer akzeptiert nur `video-status`, `video-start` und `video-stop` für registrierte IDs. Container-Neuerstellung erfordert bewusste Aktualisierung der ID. Healthchecks folgen keinen Redirects. Die GUI erlaubt nur die Auswahl registrierter Dienste, keine freien Containerbefehle, Zieladressen oder Secret-Dateipfade.
+
+Deck speichert lediglich die aktive Dienst-ID. Ein Wechsel ist nur bei gestoppten Videodiensten möglich. API-Adresse und Protokoll gehören zum Dienst; beim Wechsel auf eine andere Engine ändert sich auch deren Original-API. Client-Kompatibilität wird nicht übersetzt. Die Integration setzt einen vorhandenen, eingerichteten Dienst voraus; sie installiert keine beliebige Video-Engine automatisch.
+
+Interne Verwaltungs-API: GET `/api/v1/video`, POST `/api/v1/video/service` mit `{ "id": "ltx" }`, POST `/api/v1/video/mode` mit `{ "mode": "video" }` oder `llm`. Bestehender Sitzungsschutz gilt. Start/Stop laufen asynchron und zeigen Phase sowie API-Bereitschaft.
diff --git a/endpoint.py b/endpoint.py
index 57f59a1..3f18b64 100644
--- a/endpoint.py
+++ b/endpoint.py
@@ -36,7 +36,7 @@ class Endpoint:
def rows(self):
rows=self.profiles.status()['profiles']
with self.lock:enabled=set(self.config['enabled_profiles'])
- return [dict(p,enabled=(p['id']==self.video.selected if self.video else False) if p['kind']=='video' else p['id'] in enabled) for p in rows]
+ return [dict(p,enabled=p['id'] in enabled) for p in rows if p['kind']!='video']
def status(self):
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt')]:
@@ -62,11 +62,6 @@ class Endpoint:
row=next((p for p in rows if p['id']==data['id']),None)
if not row:raise ValueError('Profil nicht gefunden.')
if data['enabled'] and not row['runnable']:raise ValueError('Profil nicht ausführbar: '+' '.join(row['blockers']))
- if row['kind']=='video':
- if not self.video:raise ValueError('Video-Verwaltung nicht eingerichtet.')
- if data['enabled']:self.video.select(row['id'])
- elif self.video.selected==row['id']:self.video.select(None)
- return self.status()
with self.lock:
enabled=set(self.config['enabled_profiles'])
if data['enabled']:
@@ -99,10 +94,6 @@ class Endpoint:
http=self.http
if http:http.shutdown();http.server_close()
while True:
- if self.video:
- vs=self.video.status()
- if vs['state']=='switching':time.sleep(.1);continue
- if vs['mode']=='video':self.video.switch('llm');time.sleep(.1);continue
with self.lock:pending=self.inflight
if not pending and self.scheduler.unload_idle():break
time.sleep(.1)
@@ -159,35 +150,9 @@ class APIHandler(BaseHTTPRequestHandler):
if self.sent:return
self.send({'error':{'message':str(exc),'type':getattr(exc,'code','server_error'),'param':None,'code':getattr(exc,'code','worker_unavailable')}},getattr(exc,'status',503))
def video_route(self,ep):
- if not self.path.startswith('/v1/videos'):return False
- if not ep.video:raise APIError('Video-Worker nicht eingerichtet.',503)
- if self.command=='GET' and self.path=='/v1/videos/models':
- ident=ep.video.selected
- self.send({'object':'list','data':[{'id':'athena-video','object':'model','owned_by':'athena-deck'}] if any(p['id']==ident and p['runnable'] for p in ep.rows()) else []});return True
- if self.command=='POST' and self.path=='/v1/videos':
- if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload nicht unterstützt.')
- try:length=int(self.headers.get('Content-Length','0'))
- except ValueError:raise APIError('Ungültige Länge.')
- if not 0Athena DeckATHENA CONTROL SURFACEv0.7 · Router
+Athena DeckATHENA CONTROL SURFACEv0.7 · Router
diff --git a/network/install_remote.py b/network/install_remote.py
index 8ff23c3..d43b7f4 100644
--- a/network/install_remote.py
+++ b/network/install_remote.py
@@ -12,7 +12,7 @@ import sys
NAME = 'athena-deck-network'
BASE = Path('/opt/athena-deck')
-FILES = {'deploy/video-requirements.lock','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','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'video.py','video_worker.py','video_runtime.py','video-ui.js','catalog.py','hub_auth.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
+FILES = {'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','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'video.py','video-ui.js','catalog.py','hub_auth.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
def run(*args, **kwargs):
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
diff --git a/profiles.py b/profiles.py
index 8c9de88..5f8e988 100644
--- a/profiles.py
+++ b/profiles.py
@@ -107,10 +107,11 @@ class Profiles:
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p)
if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.')
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
- return {'profiles':rows,'schemas':SCHEMAS}
+ return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}}
def save(self,data):
if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.')
kind=data['kind'];name=data['name'];params=data['parameters']
+ if kind=='video':raise ValueError('Videoprofile werden nicht mehr in Deck verwaltet. Originale LTX-Oberfläche verwenden.')
if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not re.fullmatch('[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}',name):raise ValueError('API-Name: 2–64 Buchstaben, Ziffern, Bindestrich oder Unterstrich.')
model=self.catalog.entry(data['model_id'])
if model['repo']==QWEN_REPO and any(model['file'] in r['files'] for r in QWEN_COMPONENTS.values()):raise ValueError('Textencoder und VAE werden über Komponenten zugeordnet, nicht als Hauptmodell.')
diff --git a/server.py b/server.py
index c31bd36..4cae2b9 100644
--- a/server.py
+++ b/server.py
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
"""Athena Deck prototype: loopback API, owned model processes, read-only telemetry."""
-from video_runtime import VideoRuntime
from video import Video
from execution_setup import assess as execution_assess
import argparse
@@ -101,18 +100,15 @@ class Server(ThreadingHTTPServer):
self.endpoint=Endpoint(self.catalog.root.parent,self.profiles,self.worker,self.scheduler,self.image_tests,self.credentials,self.server_port)
self.endpoint.tts=self.tts_tests
self.endpoint.stt=self.stt
+ self.chat_tests=ChatTests(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():
+ self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.tts_tests.stop();self.worker.stop()
+ self.video=Video(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video')
+ self.endpoint.video=self.video
if self.endpoint.config['autostart']:
try:self.endpoint.start()
except ValueError as exc:self.endpoint.error=str(exc)
- self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler)
- self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
- self.video_runtime=VideoRuntime(self.catalog.root.parent/'video-runtime')
- def stop_gpu_work():
- self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.tts_tests.stop();self.worker.stop()
- self.video=Video(self.catalog.root.parent/'video',self.profiles,self.video_runtime,self.scheduler,stop_gpu_work)
- self.profiles.video_blockers=self.video.blockers
- self.profiles.video_runtime_ready=lambda:self.video_runtime.status()["installed"]
- self.endpoint.video=self.video
self.sessions = {}
self.login_attempts = []
self.auth_lock = threading.Lock()
@@ -254,7 +250,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':self.server.video_runtime.status().get('installed',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':False}
return execution_assess(model,installed,profile)
def do_GET(self):
@@ -285,16 +281,7 @@ class Handler(BaseHTTPRequestHandler):
if urlsplit(self.path).path == '/api/v1/tts/audio':
try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
except (OSError,ValueError):return self.respond({'error':'Audio nicht verfügbar.'},404)
- if urlsplit(self.path).path == '/api/v1/video/content':
- try:
- path=self.server.video.result(parse_qs(urlsplit(self.path).query).get('id',[''])[0])
- self.send_response(200);self.send_header('Content-Type','video/mp4');self.send_header('Content-Length',str(path.stat().st_size));self.send_header('Cache-Control','no-store');self.end_headers()
- with path.open('rb') as source:
- while chunk:=source.read(1024*1024):self.wfile.write(chunk)
- return
- except ValueError as exc:return self.respond({'error':str(exc)},404)
if self.path == '/api/v1/video':return self.respond(self.server.video.status())
- if self.path == '/api/v1/video-runtime':return self.respond(self.server.video_runtime.status())
if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status())
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
if urlsplit(self.path).path == '/api/v1/image-tests/image':
@@ -408,16 +395,13 @@ class Handler(BaseHTTPRequestHandler):
if data:raise ValueError('Keine Parameter erwartet.')
return self.respond(self.server.chat_tests.stop() if self.path.endswith('/cancel') else self.server.chat_tests.unload())
except ValueError as exc:return self.respond({'error':str(exc)},400)
- if self.path in ('/api/v1/video/mode','/api/v1/video/profile','/api/v1/video/generate','/api/v1/video-runtime/install','/api/v1/video-runtime/cancel'):
+ if self.path in ('/api/v1/video/mode','/api/v1/video/service'):
try:
data=self.read_json()
- if self.path.endswith('/mode') and set(data)=={'mode'}:return self.respond(self.server.video.switch(data['mode']))
- if self.path.endswith('/profile') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id']))
- if self.path.endswith('/generate'):return self.respond(self.server.video.generate(data),202)
- if data:raise ValueError('Ungültige Video-Parameter.')
- return self.respond(self.server.video_runtime.start() if self.path.endswith('/install') else self.server.video_runtime.stop())
- except ValueError as exc:return self.respond({'error':str(exc)},400)
- except Exception:return self.respond({'error':'Video-Aktion fehlgeschlagen.'},503)
+ if self.path.endswith('/service') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id']))
+ if set(data)!={'mode'}:raise ValueError('Nur Modus llm oder video erwartet.')
+ return self.respond(self.server.video.switch(data['mode']))
+ except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'):
try:
data=self.read_json();ep=self.server.endpoint
@@ -475,7 +459,6 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/profiles/delete':
try:
data=self.read_json()
- if self.server.video.selected==data.get('id') and self.server.video.status()['state'] in ('ready','switching'):raise ValueError('Aktives Videoprofil zuerst durch Wechsel auf LLM entladen.')
with self.server.image_tests.lock, self.server.tts_tests.lock, self.server.stt.lock:
stt_job=self.server.stt.job
if stt_job and stt_job.get("state")=="running" and stt_job.get("profile_id")==data.get("id"):raise ValueError("Dieses STT-Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.")
@@ -538,7 +521,6 @@ def main():
server.auto_tests.stop()
server.chat_tests.stop()
server.video.close()
- server.video_runtime.stop()
server.endpoint.close()
server.stt.stop()
server.tts_tests.stop()
diff --git a/studio.js b/studio.js
index c9eedad..7fb0c2e 100644
--- a/studio.js
+++ b/studio.js
@@ -4,18 +4,19 @@ const Studio=(()=>{
const labels={chat:'Sprachmodelle / Chat',image:'Bildgenerierung',audio:'Text-to-Speech (TTS)',stt:'Spracherkennung (STT)',music:'Musik',voice:'Stimme / Voice',video:'Video'};
const audioDescriptions={audio:'Geschriebenen Text vorlesen lassen. Die Suche zeigt Text-to-Speech-Modelle.',stt:'Gesprochene Sprache in Text umwandeln. Die Suche zeigt Spracherkennungsmodelle.',music:'Musik und Klänge aus Text erzeugen. Die Hub-Kategorie Text-to-Audio umfasst neben Musik auch Geräusche und andere Audioerzeugung.',voice:'Vorhandenes Audio bearbeiten: Stimmumwandlung, Audiotrennung und weitere Audio-to-Audio-Modelle. Eigenständige Studio-Oberflächen gehören unter Weitere Dienste.'};
function render(page,force=false,modelId=null){
+ if(page==='video'){VideoUI.runtime();return;}
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
if(category!==page){category=page;section='discover';}
if(modelId)section='profiles';
- const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`
ATHENA / MODELLVERWALTUNG
${labels[page]}
${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}
Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.
Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.
Laufzeiten werden einmal eingerichtet und für kompatible Modelle wiederverwendet. Entdecken, Bibliothek und Profile zeigen den passenden Einrichtungsweg; unbekannte Modelle werden als noch nicht unterstützt gekennzeichnet. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.
Video
LTX-Pipelines · eigener Worker mit exklusivem GPU-Modus.
Laufzeiten werden einmal eingerichtet und für kompatible Modelle wiederverwendet. Entdecken, Bibliothek und Profile zeigen den passenden Einrichtungsweg; unbekannte Modelle werden als noch nicht unterstützt gekennzeichnet. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.
Spracherkennung
Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.
Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF und das unterstützte FLUX.2-Klein-9B-Modell. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.
Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Bei großen FLUX-Gewichten wird zusätzlich System-RAM genutzt. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.
Installationsstatus wird geprüft …
Bildgenerierung → Testen öffnen${ImageTestUI.html(true)}`;}
function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile};
diff --git a/test_docker_support.py b/test_docker_support.py
index 53e9e78..9b92431 100644
--- a/test_docker_support.py
+++ b/test_docker_support.py
@@ -48,3 +48,14 @@ class DockerTests(unittest.TestCase):
manager=Manager(d,True);manager.job={'state':'running'}
with patch('deploy.docker_helper.release',return_value={'VERSION_ID':'13'}),patch('deploy.docker_helper.run',return_value=Mock(stdout='amd64')),patch.object(manager,'_checked',side_effect=ValueError('failed')):manager._install()
self.assertEqual(manager.job['state'],'failed')
+
+ def test_video_controller_only_uses_registered_container_and_refuses_busy_gpu(self):
+ with tempfile.TemporaryDirectory() as d:
+ manager=Manager(d);config=dict(id='ltx',name='LTX',container_id='a'*64)
+ with patch.object(manager,'video_configs',return_value=[config]),patch.object(manager,'video_status',return_value={'running':False}),patch('deploy.docker_helper.run',return_value=Mock(returncode=0,stdout='1234')) as run:
+ with self.assertRaises(ValueError):manager.dispatch({'action':'video-start','service':'ltx'})
+ self.assertFalse(any('start' in c.args[0] for c in run.call_args_list))
+ with self.assertRaises(ValueError):manager.dispatch({'action':'video-stop','service':'arbitrary'})
+ with patch.object(manager,'video_configs',return_value=[config]),patch.object(manager,'video_status',return_value={'running':False}),patch('deploy.docker_helper.run',return_value=Mock(returncode=0,stdout='')) as run:
+ manager.dispatch({'action':'video-start','service':'ltx'})
+ self.assertEqual(run.call_args.args[0],['/usr/bin/docker','start','a'*64])
diff --git a/test_endpoint.py b/test_endpoint.py
index 3adc9ae..3646a1f 100644
--- a/test_endpoint.py
+++ b/test_endpoint.py
@@ -72,31 +72,19 @@ class EndpointTests(unittest.TestCase):
self.assertEqual(self.worker.requests[-1].get('reasoning_effort'),'max' if effort=='ultra' else effort)
for effort in ([],True,3,'invalid'):
self.assertEqual(self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')],reasoning_effort=effort))[0],400)
- def test_video_publication_is_exclusive_and_can_be_removed(self):
- for name in ('v1','v2'):
- self.rows.append(dict(id=name,name=name,kind='video',runnable=True,blockers=[],parameters={},updated_at=1))
- video=SimpleNamespace(selected=None,status=lambda:{})
- video.select=lambda ident:setattr(video,'selected',ident)
- self.ep.video=video
- self.ep.enable({'id':'v1','enabled':True})
- self.assertEqual([p['id'] for p in self.ep.rows() if p['kind']=='video' and p['enabled']],['v1'])
- self.ep.enable({'id':'v2','enabled':True})
- self.assertEqual([p['id'] for p in self.ep.rows() if p['kind']=='video' and p['enabled']],['v2'])
- self.ep.enable({'id':'v1','enabled':False});self.assertEqual(video.selected,'v2')
- self.ep.enable({'id':'v2','enabled':False});self.assertIsNone(video.selected)
- self.ep.video=None
+ def test_video_profiles_not_published(self):
+ self.rows.append(dict(id='video-old',name='old',kind='video',runnable=True,blockers=[],parameters={},updated_at=1))
+ self.assertFalse(any(p['kind']=='video' for p in self.ep.rows()))
+ with self.assertRaises(ValueError):self.ep.enable({'id':'video-old','enabled':True})
def test_video_mode_rejects_chat_without_starting_llama(self):
self.ep.video=Mock();self.ep.scheduler.gpu_mode='video'
code,body=self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')]))
self.assertEqual(code,503);self.assertEqual(body['error']['code'],'video_mode_active');self.assertFalse(self.worker.started)
self.ep.video=None
- def test_video_job_api_and_auth(self):
- self.ep.video=Mock();self.ep.video.generate.return_value={'id':'a'*32,'state':'running'}
- self.ep.video.status.return_value={'job':{'id':'a'*32,'state':'complete'}}
- self.assertEqual(self.request('/v1/videos',{'prompt':'synthetic'})[0],202)
- self.assertEqual(self.request('/v1/videos/'+'a'*32)[1]['state'],'complete')
- self.assertEqual(self.request('/v1/videos/'+'b'*32)[0],404)
- 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')
+
def test_stt_endpoint_multipart_and_publication(self):
from test_stt import audio
self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={}))
diff --git a/test_video.py b/test_video.py
index 38cc69e..4066f68 100644
--- a/test_video.py
+++ b/test_video.py
@@ -1,65 +1,36 @@
import tempfile
-import time
import unittest
-from pathlib import Path
-from unittest.mock import Mock,patch
-from video import Video
+from unittest.mock import Mock
from inference import Scheduler,InferenceError
+from video import Video
+class Helper:
+ def __init__(self):self.services=[dict(id='ltx',name='LTX',running=False,ready=False),dict(id='other',name='Other',running=False,ready=False)];self.actions=[];self.fail_start=False
+ def call(self,action,ident=None):
+ self.actions.append((action,ident))
+ if action=='video-status':return {'services':[dict(s) for s in self.services]}
+ if action=='video-start' and self.fail_start:raise ValueError('GPU belegt')
+ row=next(s for s in self.services if s['id']==ident);row['running']=row['ready']=action=='video-start';return dict(row)
class VideoTests(unittest.TestCase):
- def make(self,d):
- scheduler=Scheduler(Mock());profiles=Mock();profiles.status.return_value={'profiles':[dict(id='v',name='test',kind='video')]}
- return Video(Path(d),profiles,Mock(),scheduler,Mock())
- def wait(self,v):
- v.thread.join(3);self.assertFalse(v.thread.is_alive())
- def test_exclusive_mode_stops_owned_work_and_restores_llm(self):
+ def test_switch_and_persistent_provider_selection(self):
with tempfile.TemporaryDirectory() as d:
- v=self.make(d);v.select('v');v.blockers=Mock(return_value=[]);v.load=Mock()
- v.switch('video');self.wait(v)
- self.assertEqual(v.mode,'video');v.stop_owned.assert_called_once();v.load.assert_called_once()
+ h=Helper();stop=Mock();scheduler=Scheduler(Mock());v=Video(scheduler,stop,h,d)
+ v.select('ltx');self.assertEqual(Video(Scheduler(Mock()),Mock(),h,d).selected,'ltx')
+ v.switch('video');v.thread.join(3);self.assertEqual(v.status()['mode'],'video');stop.assert_called_once()
with self.assertRaises(InferenceError):
- with v.scheduler.lease(('chat',)):pass
- with self.assertRaises(ValueError):v.select('v')
- v.switch('llm');self.wait(v);self.assertEqual(v.scheduler.gpu_mode,'llm')
- with v.scheduler.lease(('chat',)):pass
- def test_failed_load_does_not_leave_gpu_gate_locked(self):
+ with scheduler.lease(('chat',)):pass
+ with self.assertRaises(ValueError):v.select('other')
+ v.switch('llm');v.thread.join(3);self.assertEqual(v.status()['mode'],'llm')
+ v.select('other');v.switch('video');v.thread.join(3);self.assertIn(('video-start','other'),h.actions)
+ def test_restart_adopts_running_service_without_stopping_it(self):
with tempfile.TemporaryDirectory() as d:
- v=self.make(d);v.select('v');v.blockers=Mock(return_value=[]);v.load=Mock(side_effect=ValueError('busy'))
- v.switch('video');self.wait(v)
- self.assertEqual(v.status()['error'],'busy');self.assertEqual(v.scheduler.gpu_mode,'llm')
- def test_clear_video_selection_persists(self):
+ h=Helper();h.services[0].update(running=True,ready=True);v=Video(Scheduler(Mock()),Mock(),h,d)
+ self.assertEqual(v.status()['mode'],'video');v.close();self.assertFalse(any(a=='video-stop' for a,_ in h.actions))
+ def test_foreign_gpu_failure_reopens_llm_without_starting_service(self):
with tempfile.TemporaryDirectory() as d:
- v=self.make(d);v.select('v');v.select(None)
- self.assertIsNone(v.selected);self.assertIsNone(self.make(d).selected)
- with self.assertRaises(ValueError):v.switch('video')
- def test_foreign_process_prevents_spawn(self):
- with tempfile.TemporaryDirectory() as d,patch('video.probe',return_value=[{'processes':1}]),patch('video.subprocess.Popen') as spawn:
- v=self.make(d)
- with self.assertRaises(ValueError):v.load({})
- spawn.assert_not_called()
- def test_request_parameters_override_defaults_and_validate(self):
+ h=Helper();h.fail_start=True;v=Video(Scheduler(Mock()),Mock(),h,d);v.select('ltx');v.switch('video');v.thread.join(3)
+ self.assertEqual(v.status()['mode'],'llm');self.assertEqual(v.error,'GPU belegt')
+ def test_helper_loss_fails_closed_for_configured_service(self):
with tempfile.TemporaryDirectory() as d:
- v=self.make(d);v.mode='video';v.state='ready';v.profile={'id':'v','name':'test','parameters':dict(width=512,height=320,fps=24,frames=9,seed=42)}
- with patch('video.threading.Thread') as thread,patch('video.shutil.disk_usage') as disk:
- disk.return_value.free=100*1024**3
- result=v.generate({'prompt':'synthetic','width':640})
- self.assertEqual(result['parameters']['width'],640);self.assertEqual(result['parameters']['fps'],24)
- self.assertNotIn('prompt',str(v.status()))
- with self.assertRaises(ValueError):v.generate({'prompt':'synthetic'})
- v.job=None
- for extra in [{'frames':10},{'fps':float('nan')},{'width':True},{'model':'wrong'},{'steps':100}]:
- with self.assertRaises(ValueError):v.generate(dict(prompt='synthetic',**extra))
- def test_multiple_worker_messages_in_one_read(self):
- import subprocess,sys
- with tempfile.TemporaryDirectory() as d:
- v=self.make(d);v.job={'id':'test'}
- p=subprocess.Popen([sys.executable,'-c','import sys,time;sys.stdout.write(\'{"state":"progress","phase":"test"}\\n{"state":"complete"}\\n\');sys.stdout.flush();time.sleep(3)'],stdout=subprocess.PIPE,bufsize=0)
- v.process=p
- try:
- self.assertEqual(v.read(p,1)['state'],'complete');self.assertEqual(v.job['phase'],'test')
- finally:p.terminate();p.wait();p.stdout.close()
- def test_missing_components_leave_current_mode_untouched(self):
- with tempfile.TemporaryDirectory() as d:
- v=self.make(d);v.select('v');v.blockers=Mock(return_value=['missing'])
- with self.assertRaises(ValueError):v.switch('video')
- self.assertEqual(v.scheduler.gpu_mode,'llm');v.stop_owned.assert_not_called()
+ h=Helper();v=Video(Scheduler(Mock()),Mock(),h,d);v.select('ltx');h.call=Mock(side_effect=OSError())
+ self.assertEqual(v.status()['mode'],'video')
diff --git a/test_video_components.py b/test_video_components.py
index 168d911..3612a22 100644
--- a/test_video_components.py
+++ b/test_video_components.py
@@ -18,7 +18,7 @@ class VideoComponentsTests(unittest.TestCase):
result=p.components('main');self.assertEqual(len(result['requirements']),4);c.files.assert_called_with(LTX_REPO,'a'*40)
self.assertTrue(all(len(r['available'])==1 for r in result['requirements']))
p.assign(dict(id='p',revision=1,components={x['id']:x['id'] for x in entries}))
- self.assertFalse(p.status()['profiles'][0]['runnable']);self.assertEqual(len(p.status()['profiles'][0]['blockers']),1)
+ self.assertEqual(p.status()['profiles'],[])
entries[0]['revision']='b'*40
with self.assertRaises(ValueError):p._component(model,'text_encoder','text_encoder')
with self.assertRaises(ValueError):p.assign(dict(id='p',revision=2,components={'unknown':''}))
diff --git a/test_video_devices.py b/test_video_devices.py
deleted file mode 100644
index 17504be..0000000
--- a/test_video_devices.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import tempfile
-import unittest
-from collections import namedtuple
-from contextlib import nullcontext
-from pathlib import Path
-from unittest.mock import Mock,patch
-from profiles import Profiles,SCHEMAS,video_parameters
-from video import video_devices
-from video_worker import EncoderOnDevice
-
-class VideoDeviceTests(unittest.TestCase):
- def test_selection_is_by_uuid_not_physical_index(self):
- cards=[dict(uuid='GPU-second',name='RTX 3060',total_mib=12000),dict(uuid='GPU-first',name='RTX 5080',total_mib=16000)]
- main,encoder,ordered=video_devices({'parameters':{'text_encoder_device':'GPU-second'}},cards)
- self.assertEqual([g['uuid'] for g in ordered],['GPU-first','GPU-second'])
- self.assertEqual(video_devices({'parameters':{}},cards)[2],[main])
- self.assertEqual(video_devices({'parameters':{'video_device':'GPU-second'}},cards)[2],[encoder])
- with self.assertRaises(ValueError):video_devices({'parameters':{'text_encoder_device':'GPU-missing'}},cards)
- def test_parameters_persist_and_old_profiles_default_to_same_gpu(self):
- with tempfile.TemporaryDirectory() as d:
- catalog=Mock();catalog.entry.return_value=dict(id='m',kind='video',repo='example/repo',file='model.safetensors',profile_eligible=True)
- store=Profiles(Path(d)/'profiles.json',catalog)
- params={k:v[2] for k,v in SCHEMAS['video'].items()}
- row=store.save(dict(id=None,revision=0,name='video-test',kind='video',model_id='m',parameters=params))
- self.assertEqual(row['parameters']['text_encoder_device'],'same')
- row['parameters'].update(video_device='GPU-first',text_encoder_device='GPU-second')
- store.save({k:row[k] for k in ('id','revision','name','kind','model_id','parameters')})
- self.assertEqual(Profiles(Path(d)/'profiles.json',catalog).rows[0]['parameters']['text_encoder_device'],'GPU-second')
- for value in ['cpu','cuda:1',None,{},'GPU-abc;bad']:
- with self.assertRaises(ValueError):video_parameters({'text_encoder_device':value})
- def test_encoder_moves_conditioning_back_and_preserves_optional_audio(self):
- result=namedtuple('Embedding','video_encoding audio_encoding attention_mask')
- video,mask=Mock(),Mock();encoder=Mock(return_value=[result(video,None,mask)])
- torch=Mock();torch.cuda.device.side_effect=lambda device:nullcontext()
- with patch.dict('sys.modules',{'torch':torch}):
- output=EncoderOnDevice(encoder,'cuda:1','cuda:0')(['synthetic'])
- torch.cuda.device.assert_called_once_with('cuda:1')
- video.to.assert_called_once_with('cuda:0');mask.to.assert_called_once_with('cuda:0')
- self.assertIsNone(output[0].audio_encoding)
diff --git a/video-ui.js b/video-ui.js
index 2f390ad..a707093 100644
--- a/video-ui.js
+++ b/video-ui.js
@@ -1,74 +1,15 @@
window.VideoUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
- const labels={llm:'LLM',video:'Video',switching:'Wechsel läuft',idle:'Bereit für LLM',ready:'Videoprofil bereit',failed:'Fehlgeschlagen',running:'Video wird erzeugt',complete:'Video fertig'};
- const elapsed=t=>{const seconds=Math.max(0,Math.floor(Date.now()/1000-t));return `${Math.floor(seconds/60)} Min. ${seconds%60} Sek.`;};
- const busy=(title,detail='')=>`
${e(title)}
${e(detail)}
`;
- async function api(path,data){const r=await fetch('/api/v1/'+path,data!==undefined?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{}),s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
- function workerState(s){
- if(s.state==='switching')return busy(`Wechsel zu ${labels[s.target_mode]||'anderem Modus'}`,`${s.switch_phase||'Laufende Aufträge beenden und Speicher freigeben'}${s.switch_started_at?' · '+elapsed(s.switch_started_at):''}`);
- return `
`;
- }
- function jobState(job){
- if(!job)return '
Noch kein Video-Test gestartet.
';
- if(job.state==='running')return busy('Video-Test läuft',`${job.phase||'Auftrag wird vorbereitet'} · Laufzeit ${elapsed(job.created_at)}. BF16 mit Disk-Streaming kann mehrere Minuten benötigen. Eine verlässliche Prozentanzeige liefert der Worker noch nicht.`);
- return `
${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?`
Original-API: ${e(s.service.api_url)}
${e(s.service.message)}
`:''}${s.error?`
${e(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 unter Weitere Dienste 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);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}});
}
- function html(view='test'){
- const test=view==='test',runtime=view==='runtime';
- return `
Deck verwaltet keine Videoprofile. Der aktive Dienst wird in der Übersicht gestartet und über seine Original-API bedient.
Zur Übersicht';services(view.querySelector('#video-services'));}
+ return {mode,services,runtime};
})();
diff --git a/video.py b/video.py
index d8e0f4e..9bc5d8e 100644
--- a/video.py
+++ b/video.py
@@ -1,190 +1,71 @@
-"""Exclusive GPU mode and owned persistent LTX subprocess with asynchronous jobs."""
+"""Deck controls the original Videodienst service lifecycle, never video generation."""
import json
-import os
from pathlib import Path
-import re
-import selectors
-import signal
-import shutil
-import subprocess
import threading
import time
-import uuid
-from image_test import probe,cgroup_headroom,GIB
-from profiles import video_recipe,video_parameters
-
-def video_devices(profile,gpus):
- if not gpus:raise ValueError('Keine Video-GPU verfügbar.')
- params=video_parameters(profile.get('parameters',{}))
- def selected(value):
- found=next((g for g in gpus if g['uuid']==value),None)
- if not found:raise ValueError('Im Videoprofil ausgewählte GPU nicht verfügbar: '+value)
- return found
- gpu=(next((g for g in gpus if '5080' in g.get('name','')),None) or max(gpus,key=lambda g:g['total_mib'])) if params['video_device']=='auto' else selected(params['video_device'])
- encoder=gpu if params['text_encoder_device']=='same' else selected(params['text_encoder_device'])
- devices=[gpu] if encoder['uuid']==gpu['uuid'] else [gpu,encoder]
- return gpu,encoder,devices
class Video:
- def __init__(self,root,profiles,runtime,scheduler,stop_owned):
- self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.scheduler=scheduler;self.stop_owned=stop_owned
- self.lock=threading.RLock();self.mode='llm';self.state='idle';self.error=None;self.process=None;self.profile=None;self.job=None;self.thread=None;self.gpu=None;self.device_names={}
- self.switch_phase=None;self.switch_started_at=None;self.target_mode=None;self.closing=False;self.selected=None
- if (self.root/'selection.json').exists():self.selected=json.loads((self.root/'selection.json').read_text()).get('profile_id')
- def status(self):
- with self.lock:
- if self.process and self.process.poll() is not None and self.state=='ready':self.state='failed';self.error='Video-Worker beendet; RAM-OOM oder Laufzeitfehler möglich.'
- return dict(devices=dict(self.device_names),target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,mode=self.mode,state=self.state,error=self.error,profile_name=self.profile['name'] if self.profile else None,profile_id=self.selected,loaded_profile_id=self.profile['id'] if self.profile else None,gpu=self.gpu,job=dict(self.job) if self.job else None,runtime=self.runtime.status(),memory_policy='Disk-Streaming auf den im Profil gewählten GPUs; GPUs exklusiv reserviert. Gewichte werden bedarfsgerecht geladen.')
- def blockers(self,p):
- if not video_recipe(p.get('model') or {}):return ['Für diese Videovariante ist kein Worker angebunden.']
- errors=[]
- if not self.runtime.status()['installed']:errors.append('Video-Laufzeit unter Einstellungen → Laufzeiten → Video installieren.')
- for role,info in video_recipe(p['model']).items():
- try:self.profiles._component(p['model'],role,p.get('components',{}).get(role))
- except ValueError:errors.append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
- return errors
+ def __init__(self,scheduler,stop_owned,helper,root):
+ self.scheduler=scheduler;self.stop_owned=stop_owned;self.helper=helper;self.lock=threading.RLock();self.thread=None;self.state='idle';self.error=None;self.target_mode=None;self.switch_phase=None;self.switch_started_at=None
+ self.path=Path(root)/'active-service.json';self.selected=json.loads(self.path.read_text()).get('id') if self.path.exists() else None
+ self.services=[];self.service={};self.refresh()
+ def refresh(self):
+ try:self.services=self.helper.call('video-status')['services'];unknown=False
+ except (OSError,ValueError,KeyError):self.services=[];unknown=True
+ running=[s for s in self.services if s.get('running')]
+ if self.selected is None and len(running)==1:self.selected=running[0]['id']
+ if self.selected is None and len(self.services)==1:self.selected=self.services[0]['id']
+ self.service=next((s for s in self.services if s['id']==self.selected),{})
+ if self.state!='switching':
+ blocked=any(s.get('running') is not False for s in self.services) or (unknown and self.selected is not None)
+ with self.scheduler.cv:self.scheduler.gpu_mode='video' if blocked else 'llm';self.scheduler.cv.notify_all()
+ self.state='ready' if self.service.get('ready') else 'starting' if self.service.get('running') else 'failed' if blocked else 'idle'
def select(self,ident):
with self.lock:
- if self.mode!='llm' or self.state=='switching':raise ValueError('Videoprofil nur im LLM-Modus wechseln.')
- p=next((p for p in self.profiles.status()['profiles'] if p['id']==ident and p['kind']=='video'),None)
- if ident is not None and not p:raise ValueError('Videoprofil nicht gefunden.')
- self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
- tmp=self.root/'selection.tmp';tmp.write_text(json.dumps({'profile_id':ident}));tmp.replace(self.root/'selection.json');self.selected=ident
- return self.status()
+ self.refresh()
+ if self.state=='switching' or self.scheduler.gpu_mode!='llm':raise ValueError('Zuerst auf LLM wechseln und den laufenden Videodienst beenden.')
+ if not any(s['id']==ident for s in self.services):raise ValueError('Videodienst nicht eingerichtet.')
+ self.path.parent.mkdir(parents=True,exist_ok=True);temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps({'id':ident}));temp.replace(self.path);self.selected=ident
+ return self.status()
+ def status(self):
+ with self.lock:
+ self.refresh()
+ 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=dict(self.service),services=list(self.services),selected=self.selected,memory_policy='Deck steuert nur den aktiven Videodienst. Modelle, Eingaben und Generierung werden über die Original-API des Dienstes verwaltet.')
def switch(self,mode):
if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.')
with self.lock:
- if self.closing:raise ValueError('Deck wird beendet.')
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
- if mode==self.mode and self.state in ('idle','ready'):return self.status()
- profile=None
- if mode=='video':
- profile=next((p for p in self.profiles.status()['profiles'] if p['id']==self.selected),None)
- if not profile:raise ValueError('Zuerst ein aktives Videoprofil auswählen.')
- errors=self.blockers(profile)
- if errors:raise ValueError(' '.join(errors))
- # Close admission before stopping any process. Queued leases recheck this flag.
+ self.refresh()
+ if not self.service:raise ValueError('Zuerst einen eingerichteten Videodienst unter Weitere Dienste aktivieren.')
with self.scheduler.cv:self.scheduler.gpu_mode='switching';self.scheduler.cv.notify_all()
- self.state='switching';self.error=None;self.target_mode=mode;self.switch_started_at=time.time();self.switch_phase='Laufende Deck-Aufträge beenden'
- self.thread=threading.Thread(target=self._switch,args=(mode,profile),daemon=True);self.thread.start()
+ self.state='switching';self.error=None;self.target_mode=mode;self.switch_started_at=time.time();self.switch_phase='Deck-GPU-Aufträge beenden' if mode=='video' else 'Videodienst beenden und GPU-Speicher freigeben'
+ self.thread=threading.Thread(target=self._switch,args=(mode,),daemon=True);self.thread.start()
return self.status()
- def _switch(self,mode,profile):
+ def _switch(self,mode):
try:
- self.stop_owned();self.stop_process()
- with self.lock:self.switch_phase='Auf Freigabe des GPU-Speichers warten'
- deadline=time.monotonic()+60
- with self.scheduler.cv:
- while self.scheduler.active or self.scheduler.transition:
- if time.monotonic()>deadline:raise ValueError('Laufende Deck-Aufträge konnten noch nicht beendet werden. Erneut umschalten.')
- self.scheduler.cv.wait(.2)
- self.scheduler.key=None
- if self.closing:raise ValueError('Deck wird beendet.')
if mode=='video':
- with self.lock:self.switch_phase='GPUs prüfen und Videoprofil vorbereiten'
- self.load(profile)
- if self.closing:raise ValueError('Deck wird beendet.')
- with self.lock:self.mode=mode;self.state='ready' if mode=='video' else 'idle'
- with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all()
- except Exception as exc:
- self.stop_process()
- with self.lock:self.mode='llm';self.state='failed';self.error=str(exc) if isinstance(exc,ValueError) else 'Video-Moduswechsel fehlgeschlagen.'
- with self.scheduler.cv:self.scheduler.gpu_mode='llm';self.scheduler.cv.notify_all()
- def stop_process(self):
- with self.lock:
- p=self.process;self.process=None;self.profile=None;self.gpu=None
- if p:
- if p.poll() is None:
- try:
- os.killpg(p.pid,signal.SIGTERM)
- try:p.wait(timeout=5)
- except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait(timeout=5)
- except ProcessLookupError:pass
- for stream in (p.stdin,p.stdout):
- if stream:stream.close()
+ self.stop_owned()
+ deadline=time.monotonic()+60
+ with self.scheduler.cv:
+ while self.scheduler.active or self.scheduler.transition:
+ if time.monotonic()>deadline:raise ValueError('Deck-Aufträge konnten noch nicht beendet werden.')
+ self.scheduler.cv.wait(.2)
+ self.scheduler.key=None
+ self.switch_phase='Freie GPUs prüfen und Videodienst-Dienst starten'
+ self.helper.call('video-start',self.selected);self.switch_phase='Auf Original-API des Dienstes warten'
+ deadline=time.monotonic()+240
+ while True:
+ service=next(s for s in self.helper.call('video-status')['services'] if s['id']==self.selected)
+ if service.get('ready'):break
+ if not service.get('running'):raise ValueError('Videodienst-Dienst wurde beendet. Installation prüfen.')
+ if time.monotonic()>deadline:raise ValueError('Videodienst läuft, seine API ist noch nicht bereit. Erneut prüfen oder auf LLM zurückschalten.')
+ time.sleep(2)
+ else:
+ for service in self.helper.call('video-status')['services']:
+ if service.get('running'):self.helper.call('video-stop',service['id'])
+ except Exception as exc:self.error=str(exc) if isinstance(exc,ValueError) else 'Videodienst-Systemhelfer nicht erreichbar.'
+ finally:
+ with self.lock:self.state='idle';self.refresh()
def close(self):
- self.closing=True
- self.stop_process()
- if self.thread and self.thread is not threading.current_thread():self.thread.join(timeout=7)
- def read(self,p,timeout):
- deadline=time.monotonic()+timeout
- with selectors.DefaultSelector() as selector:
- selector.register(p.stdout,selectors.EVENT_READ)
- while time.monotonic()65536:raise ValueError('Ungültige Worker-Antwort.')
- p._deck_buffer=buffered
- if b'\n' not in buffered:continue
- line,p._deck_buffer=buffered.split(b'\n',1)
- result=json.loads(line)
- if result.get('state')=='progress':
- with self.lock:
- if self.job:self.job['phase']=result.get('phase','Verarbeitung')
- continue
- return result
- raise ValueError('Zeitlimit des Video-Workers erreicht.')
- def load(self,profile):
- gpus=probe()
- if not gpus or any(g['processes'] for g in gpus):raise ValueError('GPU durch fremden Dienst belegt. Fremden Dienst zuerst freigeben; Deck beendet ihn nicht.')
- gpu,encoder,devices=video_devices(profile,gpus)
- if gpu['free_mib']<12000:raise ValueError('Mindestens 12 GiB freier GPU-Speicher für den Video-Test erforderlich.')
- available=next((int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('MemAvailable:')),0)
- if available<10*GIB:raise ValueError('Mindestens 10 GiB verfügbarer Host-RAM erforderlich.')
- headroom=cgroup_headroom()
- if headroom is not None and headroom<8*GIB:raise ValueError('Mindestens 8 GiB freier Deck-RAM für Disk-Streaming erforderlich.')
- def path(entry):return str((self.profiles.catalog.root/entry['id']/('model'+Path(entry['file']).suffix)).resolve())
- components={role:self.profiles._component(profile['model'],role,profile['components'][role]) for role in video_recipe(profile['model'])}
- config=dict(paths=dict(transformer_path=path(profile['model']),text_encoder_path=path(components['text_encoder']),video_vae_path=path(components['video_vae']),audio_vae_path=path(components['audio_vae'])),spatial_upsampler=path(components['spatial_upsampler']))
- config['text_encoder_device']='cuda:0' if len(devices)==1 else 'cuda:1'
- config['device_names']={'video':gpu['name'],'text_encoder':encoder['name']}
- env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','DECK_','LLAMA_'))}
- env.update(CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in devices),HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='2',TOKENIZERS_PARALLELISM='false')
- self.root.mkdir(parents=True,exist_ok=True,mode=0o700);env.update(HOME=str(self.root),TMPDIR=str(self.root))
- python,_=self.runtime.paths()
- p=subprocess.Popen([str(python),str(Path(__file__).with_name('video_worker.py'))],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,bufsize=0,start_new_session=True,env=env)
- with self.lock:self.process=p;self.gpu=gpu['uuid'];self.device_names=dict(config['device_names'])
- if self.closing:self.stop_process();raise ValueError('Deck wird beendet.')
- with self.lock:self.switch_phase='Video-Worker laden und Komponenten prüfen'
- p.stdin.write((json.dumps(config)+'\n').encode());p.stdin.flush();result=self.read(p,180)
- if result.get('state')!='ready':raise ValueError(result.get('error','Video-Profil nicht bereit.'))
- with self.lock:self.profile=profile
- def generate(self,data):
- with self.lock:
- if self.mode!='video' or self.state!='ready' or not self.profile:raise ValueError('Video-Modus mit geladenem Profil erforderlich.')
- if self.job and self.job['state']=='running':raise ValueError('Ein Video-Auftrag läuft bereits.')
- if set(data)-{'prompt','width','height','fps','frames','seed','model'}:raise ValueError('Unbekannte Video-Parameter.')
- if data.get('model',self.profile['name']) not in (self.profile['name'],'athena-video'):raise ValueError('Angefragtes Videoprofil ist nicht aktiv.')
- prompt=data.get('prompt')
- if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=8000:raise ValueError('Prompt mit 1–8000 Zeichen erforderlich.')
- params={k:data.get(k,self.profile['parameters'][k]) for k in ('width','height','fps','frames','seed')}
- for k,lo,hi in [('width',256,1920),('height',256,1088),('fps',1,60),('frames',9,241),('seed',-1,2147483647)]:
- if type(params[k]) is not int or not lo<=params[k]<=hi:raise ValueError('Ungültiger Video-Parameter: '+k)
- if params['width']%64 or params['height']%64 or params['frames']%8!=1:raise ValueError('Breite/Höhe müssen durch 64 teilbar sein; Bildanzahl muss 8n+1 sein.')
- if params['seed']==-1:params['seed']=int.from_bytes(os.urandom(4),'big')%2147483648
- if shutil.disk_usage(self.root).free<10*GIB:raise ValueError('Mindestens 10 GiB freier Plattenspeicher erforderlich.')
- ident=uuid.uuid4().hex;out=self.root/(ident+'.mp4')
- self.job=dict(id=ident,state='running',phase='Videoauftrag gestartet',created_at=time.time(),parameters=params,error=None)
- process=self.process
- threading.Thread(target=self._generate,args=(process,dict(params,prompt=prompt,output=str(out)),ident),daemon=True).start()
- return dict(self.job)
- def _generate(self,p,data,ident):
- try:
- p.stdin.write((json.dumps(data)+'\n').encode());p.stdin.flush();result=self.read(p,3600)
- if result.get('state')!='complete':raise ValueError(result.get('error','Video-Auftrag fehlgeschlagen.'))
- with self.lock:
- if self.job['id']==ident:self.job.update(state='complete',finished_at=time.time())
- except Exception as exc:
- with self.lock:
- if self.job['id']==ident:self.job.update(state='failed',error=str(exc) if isinstance(exc,ValueError) else 'Video-Worker nicht erreichbar.')
- if self.process is p:self.state='failed';self.error=self.job['error']
- if self.process is p:self.stop_process()
- (self.root/(ident+'.mp4')).unlink(missing_ok=True)
- def result(self,ident):
- with self.lock:
- if not re.fullmatch('[a-f0-9]{32}',ident) or not self.job or self.job['id']!=ident or self.job['state']!='complete':raise ValueError('Video noch nicht verfügbar.')
- return self.root/(ident+'.mp4')
+ # Do not terminate an external service on a UI restart. Restore its gate at startup.
+ if self.thread and self.thread.is_alive():self.thread.join(5)
diff --git a/video_runtime.py b/video_runtime.py
deleted file mode 100644
index d75ddbc..0000000
--- a/video_runtime.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""Pinned, unprivileged LTX installation; no host package or driver changes."""
-import json
-from pathlib import Path
-import shutil
-import sys
-import sysconfig
-from image_runtime import ImageRuntime
-
-LTX_REV='a95ab856bf29407b6b066ede0abe1846050db56c'
-class VideoRuntime(ImageRuntime):
- def paths(self):
- marker=self.root/'active.json'
- if marker.is_file():
- ident=json.loads(marker.read_text()).get('id','')
- if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):
- base=self.root/ident
- if (base/'ready').is_file():return base/'python/bin/python',base/'ltx'
- return self.root/'not-installed/bin/python',self.root/'not-installed/ltx'
- def status(self):
- with self.lock:
- python,source=self.paths()
- return dict(installed=python.is_file() and (source/'packages/ltx-pipelines').is_dir(),job=dict(self.job) if self.job else None,revision=LTX_REV,required_disk_gib=25)
- def start(self):
- if not (Path(sysconfig.get_paths()['include'])/'Python.h').is_file():raise ValueError('Python-Entwicklungsheader fehlen (python3-dev). Deck-Installationspaket aktualisieren; native Installation benötigt zusätzlich einen C-Compiler.')
- try:return super().start()
- except ValueError as exc:raise ValueError(str(exc).replace('Bildlaufzeit','Video-Laufzeit')) from None
- def _run(self):
- base=self.root/self.job['id'];source=base/'ltx';python=base/'python/bin/python';state='failed';phase='Installation fehlgeschlagen.'
- try:
- base.mkdir(mode=0o700)
- self._phase('Eigene Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python'])
- self._phase('Gepinnte LTX-Pipelines herunterladen')
- for args in [['git','init',source],['git','-C',source,'fetch','--depth','1','https://github.com/Lightricks/LTX-2.git',LTX_REV],['git','-C',source,'checkout','--detach','FETCH_HEAD']]:self._command(args)
- self._phase('PyTorch CUDA 12.8 installieren · mehrere GiB')
- self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/video-requirements.lock','torch==2.11.0','torchaudio==2.11.0','torchvision==0.26.0','--index-url','https://download.pytorch.org/whl/cu128'])
- self._phase('LTX-Pipelines und Gemma-4-Unterstützung installieren')
- self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/video-requirements.lock','transformers==5.14.1',source/'packages/ltx-core',source/'packages/ltx-pipelines'])
- self._phase('Laufzeit prüfen')
- self._command([python,'-m','pip','check'])
- self._command([python,'-c','import sysconfig; from pathlib import Path; assert (Path(sysconfig.get_paths()["include"])/"Python.h").is_file(); import torch; from ltx_pipelines.distilled import DistilledPipeline; from ltx_core.text_encoders.gemma.gemma_assets import GemmaAssets; assert torch.version.cuda'])
- with self.lock:
- if self.cancel.is_set():raise InterruptedError()
- (base/'ready').write_text(LTX_REV)
- marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json')
- state='complete';phase='Video-Laufzeit installiert. Kein Modell gestartet.'
- except InterruptedError:state='cancelled';phase='Installation abgebrochen.'
- except Exception as exc:phase=str(exc) if isinstance(exc,RuntimeError) else 'Video-Installation fehlgeschlagen; Voraussetzungen und Speicher prüfen.'
- finally:
- if state!='complete':shutil.rmtree(base,ignore_errors=True)
- with self.lock:self.process=None;self.job.update(state=state,phase=phase);self._save()
diff --git a/video_worker.py b/video_worker.py
deleted file mode 100644
index 247599a..0000000
--- a/video_worker.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""Private stdin/stdout worker protocol. No prompts or library logs persisted."""
-import json
-import os
-import sys
-
-def reply(value):protocol.write(json.dumps(value)+'\n')
-
-class EncoderOnDevice:
- """Encode on a separate GPU; move all returned conditioning to the diffusion GPU."""
- def __init__(self,encoder,source,target):self.encoder=encoder;self.source=source;self.target=target
- def __call__(self,*args,**kwargs):
- import torch
- with torch.cuda.device(self.source):outputs=self.encoder(*args,**kwargs)
- return [type(output)(*(value.to(self.target) if value is not None else None for value in output)) for output in outputs]
-
-def main():
- import torch
- from ltx_pipelines.distilled import DistilledPipeline
- from ltx_pipelines.utils.model_paths import ModelPaths
- from ltx_pipelines.utils.types import OffloadMode
- from ltx_pipelines.utils.media_io import encode_video
- from ltx_core.model.video_vae import get_video_chunks_number
- config=json.loads(sys.stdin.readline())
- paths=ModelPaths.from_split(**config['paths'])
- pipeline=DistilledPipeline(model_paths=paths,spatial_upsampler_path=config['spatial_upsampler'],loras=(),device=torch.device('cuda:0'),offload_mode=OffloadMode.DISK)
- encoder_device=torch.device(config.get('text_encoder_device','cuda:0'))
- if encoder_device!=pipeline.device:
- from ltx_pipelines.utils.blocks import PromptEncoder
- encoder=PromptEncoder(paths,torch.bfloat16,encoder_device,offload_mode=OffloadMode.DISK)
- pipeline.prompt_encoder=EncoderOnDevice(encoder,encoder_device,pipeline.device)
- # Validate all split pack metadata without materializing BF16 weights in RAM.
- from ltx_core.text_encoders.gemma.gemma_assets import GemmaAssets
- GemmaAssets.load(config['paths']['text_encoder_path'])
- class Phase:
- def __init__(self,target,label):self.target=target;self.label=label
- def __getattr__(self,name):return getattr(self.target,name)
- def __call__(self,*args,**kwargs):
- reply({'state':'progress','phase':self.label+(' · '+config.get('device_names',{}).get('text_encoder' if self.label.startswith('Textencoder') else 'video',''))})
- return self.target(*args,**kwargs)
- for attr,label in [('prompt_encoder','Textencoder · Prompt verarbeiten'),('stage','Videogewichte laden und Diffusion berechnen'),('upsampler','Video hochskalieren'),('video_decoder','Videobilder dekodieren'),('audio_decoder','Audiospur dekodieren')]:setattr(pipeline,attr,Phase(getattr(pipeline,attr),label))
- reply({'state':'ready'})
- for line in sys.stdin:
- data=json.loads(line)
- try:
- with torch.inference_mode():
- result=pipeline(prompt=data['prompt'],seed=data['seed'],height=data['height'],width=data['width'],num_frames=data['frames'],frame_rate=data['fps'],images=[],enhance_prompt=False)
- encode_video(video=result.video,fps=data['fps'],audio=result.audio,output_path=data['output'],video_chunks_number=get_video_chunks_number(result.num_frames,result.tiling_config))
- reply({'state':'complete'})
- except torch.OutOfMemoryError:reply({'state':'failed','error':'GPU-Speicher erschöpft (CUDA OOM). Auflösung oder Bildanzahl reduzieren.'});return
- except Exception:reply({'state':'failed','error':'LTX-Generierung fehlgeschlagen. Modellformat, Laufzeit und Speicher prüfen.'});return
-if __name__=='__main__':
- # Keep stdout exclusively for bounded protocol replies, discard library output.
- protocol=os.fdopen(os.dup(sys.stdout.fileno()),'w',buffering=1)
- os.dup2(os.open(os.devnull,os.O_WRONLY),sys.stdout.fileno())
- try:main()
- except Exception:reply({'state':'failed','error':'LTX-Profil konnte nicht vorbereitet werden. Komponenten oder Laufzeit inkompatibel.'})