Replace Deck video generation with selected external service lifecycle
This commit is contained in:
@@ -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.
|
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.
|
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.
|
||||||
- `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.
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</stron
|
|||||||
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
|
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
|
||||||
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.']};
|
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;
|
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(['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==='access'){await accessPage();return;}
|
||||||
if(page==='network'){await networkPage();return;}
|
if(page==='network'){await networkPage();return;}
|
||||||
|
|||||||
+1
-2
@@ -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/*
|
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||||
COPY deploy/video-requirements.lock /app/deploy/video-requirements.lock
|
|
||||||
COPY deploy/tts-requirements.lock /app/deploy/tts-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 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/
|
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||||
|
|||||||
+47
-1
@@ -1,6 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Root-owned local helper. Only inventory and fresh Docker install; no generic RPC."""
|
"""Root-owned local helper. Only inventory and fresh Docker install; no generic RPC."""
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -97,8 +100,51 @@ class Manager:
|
|||||||
self._checked(['/usr/bin/docker','compose','version','--short'])
|
self._checked(['/usr/bin/docker','compose','version','--short'])
|
||||||
self._phase('Docker bereit. Keine Anwendungen oder Modelle installiert.','complete')
|
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')
|
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):
|
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']=='status':return self.status()
|
||||||
if data['action']=='install':return self.install()
|
if data['action']=='install':return self.install()
|
||||||
raise ValueError('Aktion nicht erlaubt.')
|
raise ValueError('Aktion nicht erlaubt.')
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||||
FILES = ['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):
|
def run(*args, check=True, interactive=False):
|
||||||
|
|||||||
@@ -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
|
|
||||||
+2
-2
@@ -7,10 +7,10 @@ MANAGED_LABEL='io.athena-deck.managed=true'
|
|||||||
ROLE_LABEL='io.athena-deck.role=application'
|
ROLE_LABEL='io.athena-deck.role=application'
|
||||||
|
|
||||||
class DockerSupport:
|
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')
|
path=os.environ.get('DECK_DOCKER_HELPER_SOCKET','/run/athena-deck-docker/control.sock')
|
||||||
with socket.socket(socket.AF_UNIX) as 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)
|
with sock.makefile('rb') as stream:raw=stream.readline(1048577)
|
||||||
if len(raw)>1048576:raise ValueError('Docker-Helferantwort zu groß.')
|
if len(raw)>1048576:raise ValueError('Docker-Helferantwort zu groß.')
|
||||||
value=json.loads(raw)
|
value=json.loads(raw)
|
||||||
|
|||||||
@@ -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}`.
|
API: `GET /api/v1/docker`, `POST /api/v1/docker/install` mit `{"confirm": true}`.
|
||||||
Referenz: https://docs.docker.com/engine/install/debian/
|
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.
|
||||||
|
|||||||
+4
-39
@@ -36,7 +36,7 @@ class Endpoint:
|
|||||||
def rows(self):
|
def rows(self):
|
||||||
rows=self.profiles.status()['profiles']
|
rows=self.profiles.status()['profiles']
|
||||||
with self.lock:enabled=set(self.config['enabled_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):
|
def status(self):
|
||||||
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
|
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')]:
|
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)
|
row=next((p for p in rows if p['id']==data['id']),None)
|
||||||
if not row:raise ValueError('Profil nicht gefunden.')
|
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 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:
|
with self.lock:
|
||||||
enabled=set(self.config['enabled_profiles'])
|
enabled=set(self.config['enabled_profiles'])
|
||||||
if data['enabled']:
|
if data['enabled']:
|
||||||
@@ -99,10 +94,6 @@ class Endpoint:
|
|||||||
http=self.http
|
http=self.http
|
||||||
if http:http.shutdown();http.server_close()
|
if http:http.shutdown();http.server_close()
|
||||||
while True:
|
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
|
with self.lock:pending=self.inflight
|
||||||
if not pending and self.scheduler.unload_idle():break
|
if not pending and self.scheduler.unload_idle():break
|
||||||
time.sleep(.1)
|
time.sleep(.1)
|
||||||
@@ -159,35 +150,9 @@ class APIHandler(BaseHTTPRequestHandler):
|
|||||||
if self.sent:return
|
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))
|
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):
|
def video_route(self,ep):
|
||||||
if not self.path.startswith('/v1/videos'):return False
|
if self.path.startswith('/v1/videos'):
|
||||||
if not ep.video:raise APIError('Video-Worker nicht eingerichtet.',503)
|
raise APIError('Video wird ausschließlich über die originale LTX-API auf dem separaten LTX-Port bedient.',410,'use_ltx_api')
|
||||||
if self.command=='GET' and self.path=='/v1/videos/models':
|
return False
|
||||||
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 0<length<=65536 or self.headers.get('Content-Type','').split(';')[0]!='application/json':raise APIError('JSON bis 64 KiB erforderlich.',413)
|
|
||||||
try:
|
|
||||||
data=json.loads(self.rfile.read(length))
|
|
||||||
if not isinstance(data,dict):raise ValueError('JSON-Objekt erforderlich.')
|
|
||||||
job=ep.video.generate(data)
|
|
||||||
except ValueError as exc:raise APIError(str(exc),409,'video_not_ready') from None
|
|
||||||
self.send(job,202);return True
|
|
||||||
parts=self.path.split('/')
|
|
||||||
if self.command=='GET' and len(parts) in (4,5) and re.fullmatch('[a-f0-9]{32}',parts[3]):
|
|
||||||
job=ep.video.status()['job']
|
|
||||||
if not job or job['id']!=parts[3]:raise APIError('Video-Auftrag nicht gefunden.',404)
|
|
||||||
if len(parts)==4:self.send(job);return True
|
|
||||||
if parts[4]=='content':
|
|
||||||
try:path=ep.video.result(parts[3])
|
|
||||||
except ValueError as exc:raise APIError(str(exc),409) from None
|
|
||||||
self.sent=True;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.send_header('Connection','close');self.end_headers()
|
|
||||||
with path.open('rb') as source:
|
|
||||||
while chunk:=source.read(1024*1024):self.wfile.write(chunk)
|
|
||||||
self.close_connection=True;return True
|
|
||||||
raise APIError('Video-Route nicht gefunden.',404)
|
|
||||||
def do_GET(self):self.route()
|
def do_GET(self):self.route()
|
||||||
def do_POST(self):self.route()
|
def do_POST(self):self.route()
|
||||||
def route(self):
|
def route(self):
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#video-runtime">Video</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
|
|
||||||
NAME = 'athena-deck-network'
|
NAME = 'athena-deck-network'
|
||||||
BASE = Path('/opt/athena-deck')
|
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):
|
def run(*args, **kwargs):
|
||||||
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
||||||
|
|||||||
+2
-1
@@ -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']=='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.')
|
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'
|
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):
|
def save(self,data):
|
||||||
if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.')
|
if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.')
|
||||||
kind=data['kind'];name=data['name'];params=data['parameters']
|
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.')
|
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'])
|
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.')
|
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.')
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Athena Deck prototype: loopback API, owned model processes, read-only telemetry."""
|
"""Athena Deck prototype: loopback API, owned model processes, read-only telemetry."""
|
||||||
from video_runtime import VideoRuntime
|
|
||||||
from video import Video
|
from video import Video
|
||||||
from execution_setup import assess as execution_assess
|
from execution_setup import assess as execution_assess
|
||||||
import argparse
|
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=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.tts=self.tts_tests
|
||||||
self.endpoint.stt=self.stt
|
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']:
|
if self.endpoint.config['autostart']:
|
||||||
try:self.endpoint.start()
|
try:self.endpoint.start()
|
||||||
except ValueError as exc:self.endpoint.error=str(exc)
|
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.sessions = {}
|
||||||
self.login_attempts = []
|
self.login_attempts = []
|
||||||
self.auth_lock = threading.Lock()
|
self.auth_lock = threading.Lock()
|
||||||
@@ -254,7 +250,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.headers.get('Host') in allowed
|
return self.headers.get('Host') in allowed
|
||||||
|
|
||||||
def execution_setup(self, model, profile=None):
|
def execution_setup(self, model, profile=None):
|
||||||
installed={'stt':bool(self.server.runtime.status().get('active')),'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False),'video':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)
|
return execution_assess(model,installed,profile)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
@@ -285,16 +281,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if urlsplit(self.path).path == '/api/v1/tts/audio':
|
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')
|
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)
|
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':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-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 self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
|
||||||
if urlsplit(self.path).path == '/api/v1/image-tests/image':
|
if urlsplit(self.path).path == '/api/v1/image-tests/image':
|
||||||
@@ -408,16 +395,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if data:raise ValueError('Keine Parameter erwartet.')
|
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())
|
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)
|
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:
|
try:
|
||||||
data=self.read_json()
|
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('/service') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id']))
|
||||||
if self.path.endswith('/profile') 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.')
|
||||||
if self.path.endswith('/generate'):return self.respond(self.server.video.generate(data),202)
|
return self.respond(self.server.video.switch(data['mode']))
|
||||||
if data:raise ValueError('Ungültige Video-Parameter.')
|
except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400)
|
||||||
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 in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'):
|
if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'):
|
||||||
try:
|
try:
|
||||||
data=self.read_json();ep=self.server.endpoint
|
data=self.read_json();ep=self.server.endpoint
|
||||||
@@ -475,7 +459,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if self.path == '/api/v1/profiles/delete':
|
if self.path == '/api/v1/profiles/delete':
|
||||||
try:
|
try:
|
||||||
data=self.read_json()
|
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:
|
with self.server.image_tests.lock, self.server.tts_tests.lock, self.server.stt.lock:
|
||||||
stt_job=self.server.stt.job
|
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.")
|
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.auto_tests.stop()
|
||||||
server.chat_tests.stop()
|
server.chat_tests.stop()
|
||||||
server.video.close()
|
server.video.close()
|
||||||
server.video_runtime.stop()
|
|
||||||
server.endpoint.close()
|
server.endpoint.close()
|
||||||
server.stt.stop()
|
server.stt.stop()
|
||||||
server.tts_tests.stop()
|
server.tts_tests.stop()
|
||||||
|
|||||||
@@ -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 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.'};
|
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){
|
function render(page,force=false,modelId=null){
|
||||||
|
if(page==='video'){VideoUI.runtime();return;}
|
||||||
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
||||||
if(category!==page){category=page;section='discover';}
|
if(category!==page){category=page;section='discover';}
|
||||||
if(modelId)section='profiles';
|
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():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt','video'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${['test','running'].includes(section)&&page==='video'?VideoUI.html(section==='running'?'running':'test'):section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
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():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
||||||
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
||||||
|
if(page==='services'){const section=document.createElement('div');document.querySelector('#studio').prepend(section);VideoUI.services(section);}
|
||||||
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
|
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
|
||||||
if(page==='stt-runtime'){STTUI.bind(true);return;}
|
if(page==='stt-runtime'){STTUI.bind(true);return;}
|
||||||
if(page==='tts-runtime'){TTSUI.bind(true);return;}
|
if(page==='tts-runtime'){TTSUI.bind(true);return;}
|
||||||
if(page==='runtime'){RuntimeUI.bind();return;}
|
if(page==='runtime'){RuntimeUI.bind();return;}
|
||||||
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
|
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
|
||||||
if(page==='runtimes')return;
|
if(page==='runtimes')return;
|
||||||
if(['test','running'].includes(section)&&page==='video')VideoUI.bind();
|
|
||||||
else if(section==='setup'){if(page==='stt')STTUI.bind(true);else TTSUI.bind(true);}
|
else if(section==='setup'){if(page==='stt')STTUI.bind(true);else TTSUI.bind(true);}
|
||||||
else if(['test','running'].includes(section)&&page==='stt')STTUI.bind();
|
else if(['test','running'].includes(section)&&page==='stt')STTUI.bind();
|
||||||
else if(['test','running'].includes(section)&&page==='audio')TTSUI.bind();
|
else if(['test','running'].includes(section)&&page==='audio')TTSUI.bind();
|
||||||
@@ -27,7 +28,7 @@ const Studio=(()=>{
|
|||||||
else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads');
|
else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads');
|
||||||
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
|
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
|
||||||
}
|
}
|
||||||
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>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.</p><div class="grid"><section class="card"><h2>Video</h2><p>LTX-Pipelines · eigener Worker mit exklusivem GPU-Modus.</p><a class="link" href="#video-runtime">Video-Laufzeit und Test →</a></section><section class="card"><h2>Spracherkennung</h2><p>Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.</p><a class="link" href="#stt-runtime">STT einrichten →</a></section><section class="card"><h2>Text-to-Speech</h2><p>Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.</p><a class="link" href="#tts-runtime">TTS einrichten →</a></section><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section><section class="card"><h2>Docker</h2><p>Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.</p><a class="link" href="#docker-runtime">Docker öffnen →</a></section></div>`;}
|
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>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.</p><div class="grid"><section class="card"><h2>Spracherkennung</h2><p>Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.</p><a class="link" href="#stt-runtime">STT einrichten →</a></section><section class="card"><h2>Text-to-Speech</h2><p>Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.</p><a class="link" href="#tts-runtime">TTS einrichten →</a></section><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section><section class="card"><h2>Docker</h2><p>Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.</p><a class="link" href="#docker-runtime">Docker öffnen →</a></section></div>`;}
|
||||||
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>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.</p><p>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.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
|
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>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.</p><p>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.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
|
||||||
function openProfile(page,modelId){render(page,true,modelId);}
|
function openProfile(page,modelId){render(page,true,modelId);}
|
||||||
return {render,openProfile};
|
return {render,openProfile};
|
||||||
|
|||||||
@@ -48,3 +48,14 @@ class DockerTests(unittest.TestCase):
|
|||||||
manager=Manager(d,True);manager.job={'state':'running'}
|
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()
|
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')
|
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])
|
||||||
|
|||||||
+8
-20
@@ -72,31 +72,19 @@ class EndpointTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.worker.requests[-1].get('reasoning_effort'),'max' if effort=='ultra' else effort)
|
self.assertEqual(self.worker.requests[-1].get('reasoning_effort'),'max' if effort=='ultra' else effort)
|
||||||
for effort in ([],True,3,'invalid'):
|
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)
|
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):
|
def test_video_profiles_not_published(self):
|
||||||
for name in ('v1','v2'):
|
self.rows.append(dict(id='video-old',name='old',kind='video',runnable=True,blockers=[],parameters={},updated_at=1))
|
||||||
self.rows.append(dict(id=name,name=name,kind='video',runnable=True,blockers=[],parameters={},updated_at=1))
|
self.assertFalse(any(p['kind']=='video' for p in self.ep.rows()))
|
||||||
video=SimpleNamespace(selected=None,status=lambda:{})
|
with self.assertRaises(ValueError):self.ep.enable({'id':'video-old','enabled':True})
|
||||||
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_mode_rejects_chat_without_starting_llama(self):
|
def test_video_mode_rejects_chat_without_starting_llama(self):
|
||||||
self.ep.video=Mock();self.ep.scheduler.gpu_mode='video'
|
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')]))
|
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.assertEqual(code,503);self.assertEqual(body['error']['code'],'video_mode_active');self.assertFalse(self.worker.started)
|
||||||
self.ep.video=None
|
self.ep.video=None
|
||||||
def test_video_job_api_and_auth(self):
|
def test_video_api_redirect_is_explicit_not_translation(self):
|
||||||
self.ep.video=Mock();self.ep.video.generate.return_value={'id':'a'*32,'state':'running'}
|
code,body=self.request('/v1/videos',{'prompt':'synthetic'})
|
||||||
self.ep.video.status.return_value={'job':{'id':'a'*32,'state':'complete'}}
|
self.assertEqual(code,410);self.assertEqual(body['error']['code'],'use_ltx_api')
|
||||||
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_stt_endpoint_multipart_and_publication(self):
|
def test_stt_endpoint_multipart_and_publication(self):
|
||||||
from test_stt import audio
|
from test_stt import audio
|
||||||
self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={}))
|
self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={}))
|
||||||
|
|||||||
+26
-55
@@ -1,65 +1,36 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from unittest.mock import Mock
|
||||||
from unittest.mock import Mock,patch
|
|
||||||
from video import Video
|
|
||||||
from inference import Scheduler,InferenceError
|
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):
|
class VideoTests(unittest.TestCase):
|
||||||
def make(self,d):
|
def test_switch_and_persistent_provider_selection(self):
|
||||||
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):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
v=self.make(d);v.select('v');v.blockers=Mock(return_value=[]);v.load=Mock()
|
h=Helper();stop=Mock();scheduler=Scheduler(Mock());v=Video(scheduler,stop,h,d)
|
||||||
v.switch('video');self.wait(v)
|
v.select('ltx');self.assertEqual(Video(Scheduler(Mock()),Mock(),h,d).selected,'ltx')
|
||||||
self.assertEqual(v.mode,'video');v.stop_owned.assert_called_once();v.load.assert_called_once()
|
v.switch('video');v.thread.join(3);self.assertEqual(v.status()['mode'],'video');stop.assert_called_once()
|
||||||
with self.assertRaises(InferenceError):
|
with self.assertRaises(InferenceError):
|
||||||
with v.scheduler.lease(('chat',)):pass
|
with scheduler.lease(('chat',)):pass
|
||||||
with self.assertRaises(ValueError):v.select('v')
|
with self.assertRaises(ValueError):v.select('other')
|
||||||
v.switch('llm');self.wait(v);self.assertEqual(v.scheduler.gpu_mode,'llm')
|
v.switch('llm');v.thread.join(3);self.assertEqual(v.status()['mode'],'llm')
|
||||||
with v.scheduler.lease(('chat',)):pass
|
v.select('other');v.switch('video');v.thread.join(3);self.assertIn(('video-start','other'),h.actions)
|
||||||
def test_failed_load_does_not_leave_gpu_gate_locked(self):
|
def test_restart_adopts_running_service_without_stopping_it(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
v=self.make(d);v.select('v');v.blockers=Mock(return_value=[]);v.load=Mock(side_effect=ValueError('busy'))
|
h=Helper();h.services[0].update(running=True,ready=True);v=Video(Scheduler(Mock()),Mock(),h,d)
|
||||||
v.switch('video');self.wait(v)
|
self.assertEqual(v.status()['mode'],'video');v.close();self.assertFalse(any(a=='video-stop' for a,_ in h.actions))
|
||||||
self.assertEqual(v.status()['error'],'busy');self.assertEqual(v.scheduler.gpu_mode,'llm')
|
def test_foreign_gpu_failure_reopens_llm_without_starting_service(self):
|
||||||
def test_clear_video_selection_persists(self):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
v=self.make(d);v.select('v');v.select(None)
|
h=Helper();h.fail_start=True;v=Video(Scheduler(Mock()),Mock(),h,d);v.select('ltx');v.switch('video');v.thread.join(3)
|
||||||
self.assertIsNone(v.selected);self.assertIsNone(self.make(d).selected)
|
self.assertEqual(v.status()['mode'],'llm');self.assertEqual(v.error,'GPU belegt')
|
||||||
with self.assertRaises(ValueError):v.switch('video')
|
def test_helper_loss_fails_closed_for_configured_service(self):
|
||||||
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):
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
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)}
|
h=Helper();v=Video(Scheduler(Mock()),Mock(),h,d);v.select('ltx');h.call=Mock(side_effect=OSError())
|
||||||
with patch('video.threading.Thread') as thread,patch('video.shutil.disk_usage') as disk:
|
self.assertEqual(v.status()['mode'],'video')
|
||||||
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()
|
|
||||||
|
|||||||
@@ -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)
|
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']))
|
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}))
|
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
|
entries[0]['revision']='b'*40
|
||||||
with self.assertRaises(ValueError):p._component(model,'text_encoder','text_encoder')
|
with self.assertRaises(ValueError):p._component(model,'text_encoder','text_encoder')
|
||||||
with self.assertRaises(ValueError):p.assign(dict(id='p',revision=2,components={'unknown':''}))
|
with self.assertRaises(ValueError):p.assign(dict(id='p',revision=2,components={'unknown':''}))
|
||||||
|
|||||||
@@ -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)
|
|
||||||
+9
-68
@@ -1,74 +1,15 @@
|
|||||||
window.VideoUI=(()=>{
|
window.VideoUI=(()=>{
|
||||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
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'};
|
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;}
|
||||||
const elapsed=t=>{const seconds=Math.max(0,Math.floor(Date.now()/1000-t));return `${Math.floor(seconds/60)} Min. ${seconds%60} Sek.`;};
|
function mode(root,s){
|
||||||
const busy=(title,detail='')=>`<div class="video-activity" role="status" aria-live="polite" aria-busy="true"><strong>${e(title)}</strong><progress aria-label="Verarbeitung läuft"></progress><p>${e(detail)}</p></div>`;
|
|
||||||
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 `<div class="video-notice ${s.error?'video-error':''}" role="status"><strong>${e(labels[s.state]||s.state)}</strong><p>GPU-Modus: ${e(labels[s.mode]||s.mode)}${s.profile_name?' · Vorbereitet: '+e(s.profile_name):''}</p>${s.profile_name&&s.devices?`<p>Videopipeline: ${e(s.devices.video||'unbekannt')} · Textencoder: ${e(s.devices.text_encoder||'unbekannt')}</p>`:''}${s.error?`<p role="alert">${e(s.error)}</p>`:''}</div>`;
|
|
||||||
}
|
|
||||||
function jobState(job){
|
|
||||||
if(!job)return '<p>Noch kein Video-Test gestartet.</p>';
|
|
||||||
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 `<div class="video-notice ${job.state==='failed'?'video-error':''}" role="status"><strong>${e(labels[job.state]||job.state)}</strong>${job.state==='complete'?'<p>Das Ergebnis steht unten zur Vorschau und zum Download bereit.</p>':''}${job.error?`<p role="alert">${e(job.error)}</p>`:''}</div>`;
|
|
||||||
}
|
|
||||||
function mode(root,s,profiles){
|
|
||||||
if(root.dataset.busy==='1')return;
|
if(root.dataset.busy==='1')return;
|
||||||
const html=`<section class="card"><h2>GPU-Modus</h2><div class="card-actions"><button data-mode="llm" ${s.mode==='llm'&&s.state!=='failed'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${s.mode==='video'&&s.state==='ready'||s.state==='switching'?'disabled':''}>Video</button></div>${workerState(s)}<p>Freigegebenes Videoprofil: <strong>${e(profiles.find(p=>p.id===s.profile_id)?.name||'Keines')}</strong> · <a class="link" href="#video">Unter Video → Profile freigeben</a></p><p>${e(s.memory_policy)}</p><p>Video beendet laufende Deck-GPU-Aufträge und sperrt Chat, Bild und TTS. CPU-STT bleibt verfügbar.</p><div class="video-message" role="status"></div></section>`;
|
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.service?.api_url?`<p>Original-API: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#services">Aktiven Videodienst unter Weitere Dienste wählen</a><div class="video-message" role="status"></div></section>`;
|
||||||
if(root._html===html)return;root._html=html;root.innerHTML=html;
|
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';}});
|
||||||
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').innerHTML=busy('Moduswechsel wird angefordert …');
|
|
||||||
let next;
|
|
||||||
try{next=await api('video/mode',{mode:b.dataset.mode});}catch(x){root.querySelector('.video-message').textContent=x.message;root.querySelectorAll('button').forEach(x=>x.disabled=false);}
|
|
||||||
finally{root.dataset.busy='0';root._html='';}
|
|
||||||
if(next&&root.isConnected)mode(root,next,profiles);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function html(view='test'){
|
function services(root){
|
||||||
const test=view==='test',runtime=view==='runtime';
|
if(!root||root.dataset.bound)return;root.dataset.bound='1';
|
||||||
return `<section id="video-runtime-page" data-video-view="${view}"><h2>${runtime?'LTX-Video-Laufzeit':test?'Video testen':'Video · Laufend'}</h2>${runtime?'<p>Eigene LTX-Python-Umgebung mit CUDA und Disk-Streaming; Gerätezuordnung im Videoprofil.</p><div id="video-install-status"></div><button id="video-install">Laufzeit installieren</button><button id="video-cancel">Installation abbrechen</button>':'<div id="video-worker-state"></div>'}<div id="video-message" role="status" aria-live="polite"></div>${test?`<section class="card"><p>Kurzer Text-zu-Video-Test mit dem freigegebenen Profil. Werte gelten nur für diesen Auftrag. Noch kein Bild-/Audio-Upload.</p><p id="video-mode-hint"></p><form id="video-test"><label>Prompt<textarea name="prompt" required maxlength="8000" rows="4" placeholder="Zum Beispiel: Ein roter Ball auf einem Holztisch, ruhige Kamera."></textarea></label><div class="video-test-fields">${[['width','Breite',256,256,1920,64],['height','Höhe',256,256,1088,64],['frames','Bilder (8n+1)',9,9,241,8],['fps','FPS',24,1,60,1],['seed','Seed',42,-1,2147483647,1]].map(([n,l,v,min,max,step])=>`<label>${l}<input type="number" name="${n}" value="${v}" min="${min}" max="${max}" step="${step}" required></label>`).join('')}</div><button id="video-generate" disabled>Video erzeugen</button></form></section>`:''}${!runtime?'<section class="card"><h3>Auftragsstatus</h3><div id="video-job" aria-live="polite"></div><div id="video-result"></div></section>':'<p>Video → Testen bietet einen kurzen Funktionstest; Video → Laufend zeigt den Worker und den aktuellen Auftrag.</p>'}</section>`;
|
async function poll(){if(!root.isConnected)return;try{const s=await api('video');if(root.dataset.busy!=='1'){root.innerHTML=`<section class="card"><h2>Aktiver Videodienst</h2><p>Die Übersicht startet den hier gewählten Dienst. Jeder Dienst behält seine eigene API; keine Übersetzung von Generierungsparametern.</p>${s.services.map(x=>`<label><input type="radio" name="video-service" value="${e(x.id)}" ${x.id===s.selected?'checked':''} ${s.mode!=='llm'?'disabled':''}> ${e(x.name)} · ${e(x.api_url)}</label>`).join('')||'<p>Noch kein Videodienst durch den Administrator zugeordnet.</p>'}<p>Zum Wechseln zuerst in der Übersicht auf LLM schalten.</p><p class="video-message" role="status"></p></section>`;root.querySelectorAll('input').forEach(b=>b.onchange=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.value});root.querySelector('.video-message').textContent='Videodienst aktiviert. Start über die Übersicht.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});}}catch(x){root.textContent=x.message;}if(root.isConnected)setTimeout(poll,5000);}poll();
|
||||||
}
|
}
|
||||||
function runtime(){const view=document.querySelector('#view');if(view.querySelector('[data-video-view="runtime"]'))return;view.innerHTML=html('runtime');bind(view.firstElementChild);}
|
function runtime(){const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;view.innerHTML='<section id="video-service-page"><h1>Videodienst</h1><p>Deck verwaltet keine Videoprofile. Der aktive Dienst wird in der Übersicht gestartet und über seine Original-API bedient.</p><a href="#home">Zur Übersicht</a><div id="video-services"></div></section>';services(view.querySelector('#video-services'));}
|
||||||
function bind(root=document.querySelector('#video-runtime-page')){
|
return {mode,services,runtime};
|
||||||
if(!root)return;
|
|
||||||
const msg=root.querySelector('#video-message'),form=root.querySelector('#video-test');let current=null,pending=false,revision=0,pollError=false;
|
|
||||||
function draw(s){
|
|
||||||
current=s;
|
|
||||||
const worker=root.querySelector('#video-worker-state');if(worker)worker.innerHTML=workerState(s);
|
|
||||||
const ready=s.mode==='video'&&s.state==='ready',running=s.job?.state==='running';
|
|
||||||
if(form){
|
|
||||||
const hint=root.querySelector('#video-mode-hint');hint.innerHTML=ready?'Videomodus bereit. Du kannst einen Test starten.':'Für diesen Test muss der Video-Modus bereit sein. Bitte in der <a class="link" href="#home">Übersicht auf Video umschalten</a>.';
|
|
||||||
form.querySelector('button').disabled=pending||running||s.state==='switching';
|
|
||||||
form.querySelector('button').textContent=pending?'Wird gestartet …':running?'Test läuft …':'Video erzeugen';
|
|
||||||
form.setAttribute('aria-busy',String(pending||running));
|
|
||||||
}
|
|
||||||
const job=root.querySelector('#video-job');if(job)job.innerHTML=jobState(s.job);
|
|
||||||
const result=root.querySelector('#video-result');
|
|
||||||
if(result&&s.job?.state==='complete'&&result.dataset.id!==s.job.id){result.dataset.id=s.job.id;result.innerHTML=`<video controls preload="metadata" class="generated-video" src="/api/v1/video/content?id=${e(s.job.id)}"></video><a class="link" href="/api/v1/video/content?id=${e(s.job.id)}" download="athena-video.mp4">Video herunterladen</a>`;}
|
|
||||||
else if(result&&s.job?.state!=='complete'){result.replaceChildren();delete result.dataset.id;}
|
|
||||||
const installation=root.querySelector('#video-install-status');
|
|
||||||
if(installation){const r=s.runtime;installation.innerHTML=r.job?.state==='running'?busy('Video-Laufzeit wird installiert',r.job.phase):`<p>${e(r.installed?'Installiert':r.job?.phase||'Noch nicht installiert')}</p>`;root.querySelector('#video-install').disabled=pending||r.installed||r.job?.state==='running';root.querySelector('#video-cancel').disabled=pending||r.job?.state!=='running';}
|
|
||||||
}
|
|
||||||
async function action(path,data){
|
|
||||||
if(pending)return;
|
|
||||||
if(path==='video/generate'&&(!current||current.mode!=='video'||current.state!=='ready')){msg.innerHTML='<p class="video-error">Falscher Modus oder Video noch nicht bereit. Bitte zuerst in der <a class="link" href="#home">Übersicht auf Video umschalten</a>.</p>';return;}
|
|
||||||
pending=true;revision++;root.querySelectorAll('button').forEach(b=>b.disabled=true);
|
|
||||||
msg.innerHTML=busy(path==='video/generate'?'Video-Test wird gestartet …':'Anfrage wird verarbeitet …');
|
|
||||||
try{const value=await api(path,data);if(!root.isConnected)return;
|
|
||||||
if(path==='video/generate'){current={...current,job:value};msg.textContent='Test gestartet. Der Fortschritt steht unter Auftragsstatus.';root.querySelector('#video-result').replaceChildren();draw(current);}else {current={...current,runtime:value};msg.textContent='Auftrag angenommen.';}
|
|
||||||
}catch(x){if(root.isConnected)msg.innerHTML=`<p class="video-error" role="alert">${e(x.message)}</p>`;}
|
|
||||||
finally{pending=false;revision++;if(root.isConnected&¤t)draw(current);}
|
|
||||||
}
|
|
||||||
root.querySelector('#video-install')?.addEventListener('click',()=>action('video-runtime/install',{}));
|
|
||||||
root.querySelector('#video-cancel')?.addEventListener('click',()=>action('video-runtime/cancel',{}));
|
|
||||||
if(form)form.onsubmit=ev=>{ev.preventDefault();const d=Object.fromEntries(new FormData(form));for(const k of ['width','height','frames','fps','seed'])d[k]=Number(d[k]);action('video/generate',d);};
|
|
||||||
async function poll(){if(!root.isConnected)return;const version=revision;
|
|
||||||
try{const s=await api('video');if(root.isConnected&&!pending&&version===revision){if(pollError){msg.textContent='Verbindung wiederhergestellt.';pollError=false;}draw(s);}}
|
|
||||||
catch(x){if(root.isConnected&&!pending&&version===revision){pollError=true;msg.innerHTML=`<p class="video-error">Status momentan nicht erreichbar: ${e(x.message)}. Ein laufender Auftrag kann weiterlaufen.</p>`;current=null;if(form)form.querySelector('button').disabled=true;}}
|
|
||||||
if(root.isConnected)setTimeout(poll,1500);
|
|
||||||
}poll();
|
|
||||||
}
|
|
||||||
return {mode,runtime,html,bind};
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
|
||||||
import selectors
|
|
||||||
import signal
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
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:
|
class Video:
|
||||||
def __init__(self,root,profiles,runtime,scheduler,stop_owned):
|
def __init__(self,scheduler,stop_owned,helper,root):
|
||||||
self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.scheduler=scheduler;self.stop_owned=stop_owned
|
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.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.path=Path(root)/'active-service.json';self.selected=json.loads(self.path.read_text()).get('id') if self.path.exists() else None
|
||||||
self.switch_phase=None;self.switch_started_at=None;self.target_mode=None;self.closing=False;self.selected=None
|
self.services=[];self.service={};self.refresh()
|
||||||
if (self.root/'selection.json').exists():self.selected=json.loads((self.root/'selection.json').read_text()).get('profile_id')
|
def refresh(self):
|
||||||
def status(self):
|
try:self.services=self.helper.call('video-status')['services'];unknown=False
|
||||||
with self.lock:
|
except (OSError,ValueError,KeyError):self.services=[];unknown=True
|
||||||
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.'
|
running=[s for s in self.services if s.get('running')]
|
||||||
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.')
|
if self.selected is None and len(running)==1:self.selected=running[0]['id']
|
||||||
def blockers(self,p):
|
if self.selected is None and len(self.services)==1:self.selected=self.services[0]['id']
|
||||||
if not video_recipe(p.get('model') or {}):return ['Für diese Videovariante ist kein Worker angebunden.']
|
self.service=next((s for s in self.services if s['id']==self.selected),{})
|
||||||
errors=[]
|
if self.state!='switching':
|
||||||
if not self.runtime.status()['installed']:errors.append('Video-Laufzeit unter Einstellungen → Laufzeiten → Video installieren.')
|
blocked=any(s.get('running') is not False for s in self.services) or (unknown and self.selected is not None)
|
||||||
for role,info in video_recipe(p['model']).items():
|
with self.scheduler.cv:self.scheduler.gpu_mode='video' if blocked else 'llm';self.scheduler.cv.notify_all()
|
||||||
try:self.profiles._component(p['model'],role,p.get('components',{}).get(role))
|
self.state='ready' if self.service.get('ready') else 'starting' if self.service.get('running') else 'failed' if blocked else 'idle'
|
||||||
except ValueError:errors.append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
|
|
||||||
return errors
|
|
||||||
def select(self,ident):
|
def select(self,ident):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.mode!='llm' or self.state=='switching':raise ValueError('Videoprofil nur im LLM-Modus wechseln.')
|
self.refresh()
|
||||||
p=next((p for p in self.profiles.status()['profiles'] if p['id']==ident and p['kind']=='video'),None)
|
if self.state=='switching' or self.scheduler.gpu_mode!='llm':raise ValueError('Zuerst auf LLM wechseln und den laufenden Videodienst beenden.')
|
||||||
if ident is not None and not p:raise ValueError('Videoprofil nicht gefunden.')
|
if not any(s['id']==ident for s in self.services):raise ValueError('Videodienst nicht eingerichtet.')
|
||||||
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
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
|
||||||
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()
|
||||||
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):
|
def switch(self,mode):
|
||||||
if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.')
|
if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.')
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.closing:raise ValueError('Deck wird beendet.')
|
|
||||||
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
|
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
|
||||||
if mode==self.mode and self.state in ('idle','ready'):return self.status()
|
self.refresh()
|
||||||
profile=None
|
if not self.service:raise ValueError('Zuerst einen eingerichteten Videodienst unter Weitere Dienste aktivieren.')
|
||||||
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.
|
|
||||||
with self.scheduler.cv:self.scheduler.gpu_mode='switching';self.scheduler.cv.notify_all()
|
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.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,profile),daemon=True);self.thread.start()
|
self.thread=threading.Thread(target=self._switch,args=(mode,),daemon=True);self.thread.start()
|
||||||
return self.status()
|
return self.status()
|
||||||
def _switch(self,mode,profile):
|
def _switch(self,mode):
|
||||||
try:
|
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':
|
if mode=='video':
|
||||||
with self.lock:self.switch_phase='GPUs prüfen und Videoprofil vorbereiten'
|
self.stop_owned()
|
||||||
self.load(profile)
|
deadline=time.monotonic()+60
|
||||||
if self.closing:raise ValueError('Deck wird beendet.')
|
with self.scheduler.cv:
|
||||||
with self.lock:self.mode=mode;self.state='ready' if mode=='video' else 'idle'
|
while self.scheduler.active or self.scheduler.transition:
|
||||||
with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all()
|
if time.monotonic()>deadline:raise ValueError('Deck-Aufträge konnten noch nicht beendet werden.')
|
||||||
except Exception as exc:
|
self.scheduler.cv.wait(.2)
|
||||||
self.stop_process()
|
self.scheduler.key=None
|
||||||
with self.lock:self.mode='llm';self.state='failed';self.error=str(exc) if isinstance(exc,ValueError) else 'Video-Moduswechsel fehlgeschlagen.'
|
self.switch_phase='Freie GPUs prüfen und Videodienst-Dienst starten'
|
||||||
with self.scheduler.cv:self.scheduler.gpu_mode='llm';self.scheduler.cv.notify_all()
|
self.helper.call('video-start',self.selected);self.switch_phase='Auf Original-API des Dienstes warten'
|
||||||
def stop_process(self):
|
deadline=time.monotonic()+240
|
||||||
with self.lock:
|
while True:
|
||||||
p=self.process;self.process=None;self.profile=None;self.gpu=None
|
service=next(s for s in self.helper.call('video-status')['services'] if s['id']==self.selected)
|
||||||
if p:
|
if service.get('ready'):break
|
||||||
if p.poll() is None:
|
if not service.get('running'):raise ValueError('Videodienst-Dienst wurde beendet. Installation prüfen.')
|
||||||
try:
|
if time.monotonic()>deadline:raise ValueError('Videodienst läuft, seine API ist noch nicht bereit. Erneut prüfen oder auf LLM zurückschalten.')
|
||||||
os.killpg(p.pid,signal.SIGTERM)
|
time.sleep(2)
|
||||||
try:p.wait(timeout=5)
|
else:
|
||||||
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait(timeout=5)
|
for service in self.helper.call('video-status')['services']:
|
||||||
except ProcessLookupError:pass
|
if service.get('running'):self.helper.call('video-stop',service['id'])
|
||||||
for stream in (p.stdin,p.stdout):
|
except Exception as exc:self.error=str(exc) if isinstance(exc,ValueError) else 'Videodienst-Systemhelfer nicht erreichbar.'
|
||||||
if stream:stream.close()
|
finally:
|
||||||
|
with self.lock:self.state='idle';self.refresh()
|
||||||
def close(self):
|
def close(self):
|
||||||
self.closing=True
|
# Do not terminate an external service on a UI restart. Restore its gate at startup.
|
||||||
self.stop_process()
|
if self.thread and self.thread.is_alive():self.thread.join(5)
|
||||||
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()<deadline:
|
|
||||||
if self.process is not p:raise ValueError('Video-Auftrag durch Moduswechsel oder Abbruch beendet.')
|
|
||||||
buffered=getattr(p,'_deck_buffer',b'')
|
|
||||||
if b'\n' not in buffered:
|
|
||||||
if not selector.select(.5):continue
|
|
||||||
chunk=os.read(p.stdout.fileno(),8192)
|
|
||||||
if not chunk:raise ValueError('Video-Worker beendet; RAM-OOM oder Laufzeitfehler möglich.')
|
|
||||||
buffered+=chunk
|
|
||||||
if len(buffered)>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')
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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.'})
|
|
||||||
Reference in New Issue
Block a user