Add exclusive LLM and video modes with isolated LTX worker and job API
This commit is contained in:
@@ -154,3 +154,22 @@ Interne API (nur angemeldete Administratorsitzung): `GET /api/v1/huggingface` li
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Exklusiver Video-Modus und Worker
|
||||
|
||||
In der Übersicht das aktive Videoprofil auswählen, dann **Video** drücken. 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.
|
||||
|
||||
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, die Berechnung erfolgt zunächst auf der RTX 5080 mit Disk-Streaming; die RTX 3060 bleibt frei. „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.
|
||||
|
||||
Eigene Video-API auf demselben API-Port und mit demselben Bearer-Token wie Chat (keine vollständige OpenAI-Videos-Kompatibilitätszusage):
|
||||
|
||||
- `GET /v1/videos/models`: `athena-video`, wenn das ausgewählte Profil ausführbar ist.
|
||||
- `POST /v1/videos`: `{model:"athena-video",prompt:"…",width:512,height:320,frames:9,fps:24,seed:42}` → HTTP 202 mit Auftrags-ID. Der Video-Modus muss bereits bereit sein. Ein Auftrag gleichzeitig, kein automatischer Moduswechsel.
|
||||
- `GET /v1/videos/{id}`: Zustand/Phase und Fehler des letzten Auftrags, ohne Prompt.
|
||||
- `GET /v1/videos/{id}/content`: fertige MP4 einschließlich Audiospur.
|
||||
|
||||
Anfragewerte überschreiben Profilstandards. Breite/Höhe sind Vielfache von 64, 256–1920 bzw. 256–1088; Bildanzahl 9–241 in der Form 8n+1, FPS 1–60. Distilled hat eine feste Schrittfolge; das bisherige allgemeine Schritte-Profilfeld wird von diesem Adapter nicht verwendet. Resultate liegen im Deck-Zustandsverzeichnis `video/`; der erste Adapter bietet jeweils den letzten Auftrag an. Kein Prompt wird auf Platte geschrieben. Eigener Video-Test mit Vorschau/Download unter Laufzeiten → Video. 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.
|
||||
|
||||
@@ -8,6 +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 placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
|
||||
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
|
||||
if(page==='video-runtime'){VideoUI.runtime();return;}
|
||||
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
|
||||
if(page==='access'){await accessPage();return;}
|
||||
if(page==='network'){await networkPage();return;}
|
||||
|
||||
+4
-2
@@ -10,11 +10,13 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
|
||||
&& git -C /opt/deck-comfy/custom_nodes/ComfyUI-GGUF checkout 373048b8403a7820620065210a691263d4da0a61 \
|
||||
&& /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt \
|
||||
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||
COPY deploy/video-requirements.lock /app/deploy/video-requirements.lock
|
||||
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
|
||||
COPY api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py hub_auth.py auth.py collect_hardware.py /app/
|
||||
COPY 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 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 video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
|
||||
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||
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','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/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']
|
||||
|
||||
|
||||
def run(*args, check=True, interactive=False):
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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
|
||||
+3
-2
@@ -8,7 +8,7 @@ window.EndpointUI=(()=>{
|
||||
async function render(settings=false){
|
||||
const view=document.querySelector('#view');let root=view.querySelector('#endpoint-page');
|
||||
if(!root||root.dataset.settings!==String(settings)){
|
||||
sequence++;view.innerHTML=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt & Profile':'Dein Modell-Endpunkt'}</h1><p>Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></div><p id="endpoint-message" role="status"></p><div id="endpoint-summary"></div>${settings?'<section class="card"><h2>Lokaler API-Port</h2><form id="endpoint-port-form"><label>Port<input id="endpoint-port" name="port" type="number" required min="1024" max="65535"></label><button id="endpoint-port-save">Port speichern</button></form><p id="endpoint-port-help"></p><p>Der Zugriff verwendet den API-Token aus <a class="link" href="#access">Zugang & API</a>. Verwaltungsoberfläche und API haben getrennte Ports.</p></section><section class="card"><h2>Am Endpunkt angebotene Profile</h2><p>Nur explizit aktivierte, ausführbare Profile erscheinen in <code>GET /v1/models</code>. Aktivieren lädt noch kein Modell.</p><div id="endpoint-profiles"></div></section><section class="card"><h2>API-Routen</h2><p><code>POST /v1/chat/completions</code> · Text und Streaming<br><code>POST /v1/images/generations</code> · Qwen-Image-Rezept, ein Bild als b64_json<br><code>POST /v1/audio/speech</code> und <code>POST /v1/audio/transcriptions</code> · noch nicht eingerichtet (501)</p><p>Alle Routen nutzen denselben Port und Bearer-Token. Bei Bildaufträgen bestimmt das Profil Auflösung, Schritte, Guidance und Seed. Noch keine Bildbearbeitung oder Vision-Eingaben.</p></section>':''}</section>`;
|
||||
sequence++;view.innerHTML=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt & Profile':'Dein Modell-Endpunkt'}</h1><p>Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></div><p id="endpoint-message" role="status"></p><div id="video-mode-panel"></div><div id="endpoint-summary"></div>${settings?'<section class="card"><h2>Lokaler API-Port</h2><form id="endpoint-port-form"><label>Port<input id="endpoint-port" name="port" type="number" required min="1024" max="65535"></label><button id="endpoint-port-save">Port speichern</button></form><p id="endpoint-port-help"></p><p>Der Zugriff verwendet den API-Token aus <a class="link" href="#access">Zugang & API</a>. Verwaltungsoberfläche und API haben getrennte Ports.</p></section><section class="card"><h2>Am Endpunkt angebotene Profile</h2><p>Nur explizit aktivierte, ausführbare Profile erscheinen in <code>GET /v1/models</code>. Aktivieren lädt noch kein Modell.</p><div id="endpoint-profiles"></div></section><section class="card"><h2>API-Routen</h2><p><code>POST /v1/chat/completions</code> · Text und Streaming<br><code>POST /v1/images/generations</code> · Qwen-Image-Rezept, ein Bild als b64_json<br><code>POST /v1/audio/speech</code> und <code>POST /v1/audio/transcriptions</code> · noch nicht eingerichtet (501)</p><p>Alle Routen nutzen denselben Port und Bearer-Token. Bei Bildaufträgen bestimmt das Profil Auflösung, Schritte, Guidance und Seed. Noch keine Bildbearbeitung oder Vision-Eingaben.</p></section>':''}</section>`;
|
||||
root=view.querySelector('#endpoint-page');
|
||||
for(const action of ['start','stop'])root.querySelector('#endpoint-'+action).onclick=()=>mutate('/'+action,{});
|
||||
root.querySelector('#endpoint-port-form')?.addEventListener('submit',event=>{event.preventDefault();mutate('/config',{port:Number(root.querySelector('#endpoint-port').value)});});
|
||||
@@ -16,10 +16,11 @@ window.EndpointUI=(()=>{
|
||||
if(pending)return;const id=sequence;
|
||||
try{const s=await api();if(!root.isConnected||id!==sequence)return;
|
||||
put(root.querySelector('#endpoint-summary'),summary(s));
|
||||
if(s.video)VideoUI.mode(root.querySelector('#video-mode-panel'),s.video,s.profiles);
|
||||
root.querySelector('#endpoint-start').disabled=s.state!=='stopped';root.querySelector('#endpoint-stop').disabled=s.state!=='running';
|
||||
if(settings){const port=root.querySelector('#endpoint-port');if(document.activeElement!==port)port.value=s.port;port.disabled=s.state!=='stopped';root.querySelector('#endpoint-port-save').disabled=s.state!=='stopped';
|
||||
root.querySelector('#endpoint-port-help').textContent=s.allowed_ports.length?'Docker-Testinstallation: veröffentlichte Loopback-Ports '+s.allowed_ports.join(', ')+'. Portwechsel nur bei gestopptem Endpunkt. Der SSH-Tunnel muss denselben Port weiterleiten.':'Nativer Dienst: freie Ports zwischen 1024 und 65535, nur bei gestopptem Endpunkt.';
|
||||
put(root.querySelector('#endpoint-profiles'),s.profiles.map(p=>`<div class="note"><strong>${e(p.name)}</strong> · ${e(p.kind)} · ${p.runnable?'ausführbar':e(p.blockers.join(' '))}<br><button class="secondary" data-enable="${e(p.id)}" data-value="${!p.enabled}" ${!p.runnable&&!p.enabled?'disabled':''}>${p.enabled?'Am Endpunkt deaktivieren':'Am Endpunkt aktivieren'}</button></div>`).join('')||'<p>Noch keine Profile angelegt. Erstelle sie in der jeweiligen Modellkategorie.</p>');
|
||||
put(root.querySelector('#endpoint-profiles'),s.profiles.filter(p=>p.kind!=='video').map(p=>`<div class="note"><strong>${e(p.name)}</strong> · ${e(p.kind)} · ${p.runnable?'ausführbar':e(p.blockers.join(' '))}<br><button class="secondary" data-enable="${e(p.id)}" data-value="${!p.enabled}" ${!p.runnable&&!p.enabled?'disabled':''}>${p.enabled?'Am Endpunkt deaktivieren':'Am Endpunkt aktivieren'}</button></div>`).join('')||'<p>Noch keine Profile angelegt. Erstelle sie in der jeweiligen Modellkategorie.</p>');
|
||||
root.querySelectorAll('[data-enable]').forEach(b=>{const p=s.profiles.find(p=>p.id===b.dataset.enable);b.disabled=!p.runnable&&!p.enabled;b.onclick=()=>mutate('/profile',{id:b.dataset.enable,enabled:b.dataset.value==='true'});});
|
||||
}
|
||||
}catch(error){if(root.isConnected)root.querySelector('#endpoint-message').textContent=error.message;}
|
||||
|
||||
+39
-2
@@ -20,7 +20,7 @@ class APIError(ValueError):
|
||||
|
||||
class Endpoint:
|
||||
def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port):
|
||||
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.stt=None;self.credentials=credentials
|
||||
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.stt=None;self.video=None;self.credentials=credentials
|
||||
self.management_port=management_port;self.lock=threading.RLock();self.http=None;self.thread=None;self.state='stopped';self.error=None;self.inflight=0
|
||||
self.allowed_ports=[int(p) for p in os.environ.get('DECK_API_PORTS','').split(',') if p]
|
||||
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
|
||||
@@ -43,7 +43,7 @@ class Endpoint:
|
||||
subset=[p for p in rows if p['kind']==kind]
|
||||
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status()['job'] and self.tts.status()['job']['state']=='running') if kind=='audio' else bool(self.stt and self.stt.status()['job'] and self.stt.status()['job']['state']=='running') if kind=='stt' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None) or (kind=='stt' and self.stt is not None))
|
||||
scheduler_status=self.scheduler.status()
|
||||
with self.lock:return dict(state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
|
||||
with self.lock:return dict(video=self.video.status() if self.video else None,state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
|
||||
def configure(self,data):
|
||||
if set(data)!={'port'} or type(data['port']) is not int or not 1024<=data['port']<=65535:raise ValueError('Port zwischen 1024 und 65535 erforderlich.')
|
||||
port=data['port']
|
||||
@@ -61,6 +61,7 @@ class Endpoint:
|
||||
rows=self.rows()
|
||||
row=next((p for p in rows if p['id']==data['id']),None)
|
||||
if not row:raise ValueError('Profil nicht gefunden.')
|
||||
if row['kind']=='video':raise ValueError('Videoprofil in der Übersicht auswählen; Video nutzt eine exklusive Auswahl.')
|
||||
if data['enabled'] and not row['runnable']:raise ValueError('Profil nicht ausführbar: '+' '.join(row['blockers']))
|
||||
with self.lock:
|
||||
enabled=set(self.config['enabled_profiles'])
|
||||
@@ -94,6 +95,10 @@ class Endpoint:
|
||||
http=self.http
|
||||
if http:http.shutdown();http.server_close()
|
||||
while True:
|
||||
if self.video:
|
||||
vs=self.video.status()
|
||||
if vs['state']=='switching':time.sleep(.1);continue
|
||||
if vs['mode']=='video':self.video.switch('llm');time.sleep(.1);continue
|
||||
with self.lock:pending=self.inflight
|
||||
if not pending and self.scheduler.unload_idle():break
|
||||
time.sleep(.1)
|
||||
@@ -149,6 +154,36 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
def failure(self,exc):
|
||||
if self.sent:return
|
||||
self.send({'error':{'message':str(exc),'type':getattr(exc,'code','server_error'),'param':None,'code':getattr(exc,'code','worker_unavailable')}},getattr(exc,'status',503))
|
||||
def video_route(self,ep):
|
||||
if not self.path.startswith('/v1/videos'):return False
|
||||
if not ep.video:raise APIError('Video-Worker nicht eingerichtet.',503)
|
||||
if self.command=='GET' and self.path=='/v1/videos/models':
|
||||
ident=ep.video.selected
|
||||
self.send({'object':'list','data':[{'id':'athena-video','object':'model','owned_by':'athena-deck'}] if any(p['id']==ident and p['runnable'] for p in ep.rows()) else []});return True
|
||||
if self.command=='POST' and self.path=='/v1/videos':
|
||||
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload nicht unterstützt.')
|
||||
try:length=int(self.headers.get('Content-Length','0'))
|
||||
except ValueError:raise APIError('Ungültige Länge.')
|
||||
if not 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_POST(self):self.route()
|
||||
def route(self):
|
||||
@@ -162,7 +197,9 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt'}
|
||||
if self.command=='GET' and self.path in model_routes:return self.send(ep.model_list(model_routes[self.path]))
|
||||
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
|
||||
if self.video_route(ep):return
|
||||
if self.command!='POST':raise APIError('Route nicht gefunden.',404)
|
||||
if ep.video and ep.scheduler.gpu_mode!='llm' and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active')
|
||||
if self.path=='/v1/audio/transcriptions':return self.transcription(ep)
|
||||
if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech'):raise APIError('Route nicht implementiert.',404)
|
||||
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ def assess(model, installed, profile=None):
|
||||
elif kind=='audio':
|
||||
result.update(message='Dieses TTS-Modell wird noch nicht ausgeführt. Deck unterstützt derzeit die festgelegte Qwen3-TTS-CustomVoice-Variante mit eingebauten Stimmen. MLX, Base mit Referenzstimme und andere TTS-Architekturen benötigen andere Anbindungen.',alternative=dict(route='#tts-runtime',label='Unterstützte TTS-Alternative ansehen (Modellwechsel)'))
|
||||
if video_recipe(model):
|
||||
result.update(state='runtime_missing',label='Komponentenrezept vorhanden · Video-Worker fehlt',components=[x['label'] for x in video_recipe(model).values()],message='Videoprofil anlegen und dort Komponenten öffnen: passende Dateien aus der Version des Hauptmodells nachladen und zuordnen. Die LTX-Video-Laufzeit ist noch nicht an Deck angebunden.')
|
||||
result.update(runtime='LTX-Pipelines · Distilled BF16',state='runtime_missing',label='Komponenten und Video-Laufzeit einrichten',route='#video-runtime',components=[x['label'] for x in video_recipe(model).values()],message='Videoprofil anlegen und dort Komponenten öffnen: passende Dateien aus der Version des Hauptmodells nachladen und zuordnen. LTX-Laufzeit unter Einstellungen → Video installieren; danach Videoprofil in der Übersicht auswählen.')
|
||||
if not result['runtime']:return result
|
||||
result['installed']=bool(installed.get(kind))
|
||||
if kind!='chat':
|
||||
|
||||
+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="/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="#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="#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>
|
||||
|
||||
+2
-1
@@ -19,7 +19,7 @@ class InferenceError(ValueError):
|
||||
class Scheduler:
|
||||
"""FIFO admission; same-profile requests share configured slots, switches drain."""
|
||||
def __init__(self, worker):
|
||||
self.worker=worker;self.cv=threading.Condition();self.queue=[];self.key=None;self.active=0;self.transition=False
|
||||
self.worker=worker;self.cv=threading.Condition();self.queue=[];self.key=None;self.active=0;self.transition=False;self.gpu_mode="llm"
|
||||
def status(self):
|
||||
with self.cv:return dict(active_requests=self.active,waiting_requests=len(self.queue),switching=self.transition)
|
||||
@contextlib.contextmanager
|
||||
@@ -30,6 +30,7 @@ class Scheduler:
|
||||
self.queue.append(ticket)
|
||||
try:
|
||||
while True:
|
||||
if self.gpu_mode!="llm":raise InferenceError("Video-Modus aktiv oder Moduswechsel läuft; GPU-Aufträge sind gesperrt.")
|
||||
if not allowed():raise InferenceError('Endpunkt wird gestoppt oder Profil ist nicht mehr aktiviert.')
|
||||
if self.queue[0] is ticket and not self.transition and (not self.active or (self.key==key and self.active<slots)):
|
||||
self.queue.pop(0);self.active+=1;claimed=True
|
||||
|
||||
@@ -12,7 +12,7 @@ import sys
|
||||
|
||||
NAME = 'athena-deck-network'
|
||||
BASE = Path('/opt/athena-deck')
|
||||
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', '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/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'}
|
||||
|
||||
def run(*args, **kwargs):
|
||||
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ window.ProfilesUI=(()=>{
|
||||
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],panelSequence=0;
|
||||
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
|
||||
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
|
||||
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${['image','video'].includes(kind)?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
||||
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
|
||||
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions">${kind==='video'?'<a class="link" href="#home">Als Videoprofil in der Übersicht auswählen →</a>':`<label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label>`}<button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${['image','video'].includes(kind)?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
||||
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Ein Videoprofil in der Übersicht auswählen und Video-Modus aktivieren. Aufträge nutzen athena-video; Profilwerte dienen als Standards.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
|
||||
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message('Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
|
||||
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
|
||||
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
|
||||
|
||||
+3
-2
@@ -69,7 +69,7 @@ class Profiles:
|
||||
def __init__(self,path,catalog):
|
||||
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
||||
self.image_runtime_ready=lambda:False
|
||||
self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None
|
||||
self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None
|
||||
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
||||
def status(self):
|
||||
with self.lock:
|
||||
@@ -91,6 +91,7 @@ class Profiles:
|
||||
for role,info in video_recipe(p['model']).items():
|
||||
try:self._component(p['model'],role,p.get('components',{}).get(role))
|
||||
except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
|
||||
if p['kind']=='video' and self.video_blockers and p.get('model'):p['blockers']=self.video_blockers(p)
|
||||
if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p)
|
||||
if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p)
|
||||
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p)
|
||||
@@ -156,7 +157,7 @@ class Profiles:
|
||||
except ValueError:pass
|
||||
candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name]
|
||||
requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates))
|
||||
if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='LTX-Pipelines · Distilled BF16 · noch nicht an Deck angebunden',runtime_installed=False,message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Vollständige Dateien bedeuten noch keine ausführbare Video-Laufzeit oder Speicherzusage.')
|
||||
if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='LTX-Pipelines · Distilled BF16 · Einstellungen → Laufzeiten → Video',runtime_installed=bool(getattr(self,'video_runtime_ready',lambda:False)()),message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Die Video-Laufzeit separat installieren und das Profil in der Übersicht auswählen. Vollständige Dateien sind keine Speicherzusage.')
|
||||
return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(),
|
||||
message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.')
|
||||
def assign(self,data):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Athena Deck prototype: loopback API, owned model processes, read-only telemetry."""
|
||||
from video_runtime import VideoRuntime
|
||||
from video import Video
|
||||
from execution_setup import assess as execution_assess
|
||||
import argparse
|
||||
import os
|
||||
@@ -104,6 +106,13 @@ class Server(ThreadingHTTPServer):
|
||||
except ValueError as exc:self.endpoint.error=str(exc)
|
||||
self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler)
|
||||
self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
|
||||
self.video_runtime=VideoRuntime(self.catalog.root.parent/'video-runtime')
|
||||
def stop_gpu_work():
|
||||
self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.tts_tests.stop();self.worker.stop()
|
||||
self.video=Video(self.catalog.root.parent/'video',self.profiles,self.video_runtime,self.scheduler,stop_gpu_work)
|
||||
self.profiles.video_blockers=self.video.blockers
|
||||
self.profiles.video_runtime_ready=lambda:self.video_runtime.status()["installed"]
|
||||
self.endpoint.video=self.video
|
||||
self.sessions = {}
|
||||
self.login_attempts = []
|
||||
self.auth_lock = threading.Lock()
|
||||
@@ -245,7 +254,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.headers.get('Host') in allowed
|
||||
|
||||
def execution_setup(self, model, profile=None):
|
||||
installed={'stt':bool(self.server.runtime.status().get('active')),'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False)}
|
||||
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)}
|
||||
return execution_assess(model,installed,profile)
|
||||
|
||||
def do_GET(self):
|
||||
@@ -259,7 +268,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||
if not self.authenticated() and self.path == '/':
|
||||
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
||||
routes = {'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
|
||||
routes = {'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
|
||||
if self.path in routes:
|
||||
name, mime = routes[self.path]
|
||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
||||
@@ -276,6 +285,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if urlsplit(self.path).path == '/api/v1/tts/audio':
|
||||
try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
|
||||
except (OSError,ValueError):return self.respond({'error':'Audio nicht verfügbar.'},404)
|
||||
if urlsplit(self.path).path == '/api/v1/video/content':
|
||||
try:
|
||||
path=self.server.video.result(parse_qs(urlsplit(self.path).query).get('id',[''])[0])
|
||||
self.send_response(200);self.send_header('Content-Type','video/mp4');self.send_header('Content-Length',str(path.stat().st_size));self.send_header('Cache-Control','no-store');self.end_headers()
|
||||
with path.open('rb') as source:
|
||||
while chunk:=source.read(1024*1024):self.wfile.write(chunk)
|
||||
return
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},404)
|
||||
if self.path == '/api/v1/video':return self.respond(self.server.video.status())
|
||||
if self.path == '/api/v1/video-runtime':return self.respond(self.server.video_runtime.status())
|
||||
if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status())
|
||||
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
|
||||
if urlsplit(self.path).path == '/api/v1/image-tests/image':
|
||||
@@ -389,6 +408,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if data:raise ValueError('Keine Parameter erwartet.')
|
||||
return self.respond(self.server.chat_tests.stop() if self.path.endswith('/cancel') else self.server.chat_tests.unload())
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
if self.path in ('/api/v1/video/mode','/api/v1/video/profile','/api/v1/video/generate','/api/v1/video-runtime/install','/api/v1/video-runtime/cancel'):
|
||||
try:
|
||||
data=self.read_json()
|
||||
if self.path.endswith('/mode') and set(data)=={'mode'}:return self.respond(self.server.video.switch(data['mode']))
|
||||
if self.path.endswith('/profile') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id']))
|
||||
if self.path.endswith('/generate'):return self.respond(self.server.video.generate(data),202)
|
||||
if data:raise ValueError('Ungültige Video-Parameter.')
|
||||
return self.respond(self.server.video_runtime.start() if self.path.endswith('/install') else self.server.video_runtime.stop())
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
except Exception:return self.respond({'error':'Video-Aktion fehlgeschlagen.'},503)
|
||||
if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'):
|
||||
try:
|
||||
data=self.read_json();ep=self.server.endpoint
|
||||
@@ -446,6 +475,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.path == '/api/v1/profiles/delete':
|
||||
try:
|
||||
data=self.read_json()
|
||||
if self.server.video.selected==data.get('id') and self.server.video.status()['state'] in ('ready','switching'):raise ValueError('Aktives Videoprofil zuerst durch Wechsel auf LLM entladen.')
|
||||
with self.server.image_tests.lock, self.server.tts_tests.lock, self.server.stt.lock:
|
||||
stt_job=self.server.stt.job
|
||||
if stt_job and stt_job.get("state")=="running" and stt_job.get("profile_id")==data.get("id"):raise ValueError("Dieses STT-Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.")
|
||||
@@ -507,6 +537,8 @@ def main():
|
||||
finally:
|
||||
server.auto_tests.stop()
|
||||
server.chat_tests.stop()
|
||||
server.video.close()
|
||||
server.video_runtime.stop()
|
||||
server.endpoint.close()
|
||||
server.stt.stop()
|
||||
server.tts_tests.stop()
|
||||
|
||||
@@ -26,7 +26,7 @@ const Studio=(()=>{
|
||||
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);});
|
||||
}
|
||||
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 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 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);}
|
||||
return {render,openProfile};
|
||||
|
||||
@@ -72,6 +72,18 @@ class EndpointTests(unittest.TestCase):
|
||||
self.assertEqual(self.worker.requests[-1].get('reasoning_effort'),'max' if effort=='ultra' else effort)
|
||||
for effort in ([],True,3,'invalid'):
|
||||
self.assertEqual(self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')],reasoning_effort=effort))[0],400)
|
||||
def test_video_mode_rejects_chat_without_starting_llama(self):
|
||||
self.ep.video=Mock();self.ep.scheduler.gpu_mode='video'
|
||||
code,body=self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')]))
|
||||
self.assertEqual(code,503);self.assertEqual(body['error']['code'],'video_mode_active');self.assertFalse(self.worker.started)
|
||||
self.ep.video=None
|
||||
def test_video_job_api_and_auth(self):
|
||||
self.ep.video=Mock();self.ep.video.generate.return_value={'id':'a'*32,'state':'running'}
|
||||
self.ep.video.status.return_value={'job':{'id':'a'*32,'state':'complete'}}
|
||||
self.assertEqual(self.request('/v1/videos',{'prompt':'synthetic'})[0],202)
|
||||
self.assertEqual(self.request('/v1/videos/'+'a'*32)[1]['state'],'complete')
|
||||
self.assertEqual(self.request('/v1/videos/'+'b'*32)[0],404)
|
||||
self.ep.video=None
|
||||
def test_stt_endpoint_multipart_and_publication(self):
|
||||
from test_stt import audio
|
||||
self.rows.append(dict(id='stt',name='stt',kind='stt',runnable=True,blockers=[],parameters={}))
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock,patch
|
||||
from video import Video
|
||||
from inference import Scheduler,InferenceError
|
||||
|
||||
class VideoTests(unittest.TestCase):
|
||||
def make(self,d):
|
||||
scheduler=Scheduler(Mock());profiles=Mock();profiles.status.return_value={'profiles':[dict(id='v',name='test',kind='video')]}
|
||||
return Video(Path(d),profiles,Mock(),scheduler,Mock())
|
||||
def wait(self,v):
|
||||
v.thread.join(3);self.assertFalse(v.thread.is_alive())
|
||||
def test_exclusive_mode_stops_owned_work_and_restores_llm(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
v=self.make(d);v.select('v');v.blockers=Mock(return_value=[]);v.load=Mock()
|
||||
v.switch('video');self.wait(v)
|
||||
self.assertEqual(v.mode,'video');v.stop_owned.assert_called_once();v.load.assert_called_once()
|
||||
with self.assertRaises(InferenceError):
|
||||
with v.scheduler.lease(('chat',)):pass
|
||||
with self.assertRaises(ValueError):v.select('v')
|
||||
v.switch('llm');self.wait(v);self.assertEqual(v.scheduler.gpu_mode,'llm')
|
||||
with v.scheduler.lease(('chat',)):pass
|
||||
def test_failed_load_does_not_leave_gpu_gate_locked(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
v=self.make(d);v.select('v');v.blockers=Mock(return_value=[]);v.load=Mock(side_effect=ValueError('busy'))
|
||||
v.switch('video');self.wait(v)
|
||||
self.assertEqual(v.status()['error'],'busy');self.assertEqual(v.scheduler.gpu_mode,'llm')
|
||||
def test_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:
|
||||
v=self.make(d);v.mode='video';v.state='ready';v.profile={'id':'v','name':'test','parameters':dict(width=512,height=320,fps=24,frames=9,seed=42)}
|
||||
with patch('video.threading.Thread') as thread,patch('video.shutil.disk_usage') as disk:
|
||||
disk.return_value.free=100*1024**3
|
||||
result=v.generate({'prompt':'synthetic','width':640})
|
||||
self.assertEqual(result['parameters']['width'],640);self.assertEqual(result['parameters']['fps'],24)
|
||||
self.assertNotIn('prompt',str(v.status()))
|
||||
with self.assertRaises(ValueError):v.generate({'prompt':'synthetic'})
|
||||
v.job=None
|
||||
for extra in [{'frames':10},{'fps':float('nan')},{'width':True},{'model':'wrong'},{'steps':100}]:
|
||||
with self.assertRaises(ValueError):v.generate(dict(prompt='synthetic',**extra))
|
||||
def test_multiple_worker_messages_in_one_read(self):
|
||||
import subprocess,sys
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
v=self.make(d);v.job={'id':'test'}
|
||||
p=subprocess.Popen([sys.executable,'-c','import sys,time;sys.stdout.write(\'{"state":"progress","phase":"test"}\\n{"state":"complete"}\\n\');sys.stdout.flush();time.sleep(3)'],stdout=subprocess.PIPE,bufsize=0)
|
||||
v.process=p
|
||||
try:
|
||||
self.assertEqual(v.read(p,1)['state'],'complete');self.assertEqual(v.job['phase'],'test')
|
||||
finally:p.terminate();p.wait();p.stdout.close()
|
||||
def test_missing_components_leave_current_mode_untouched(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
v=self.make(d);v.select('v');v.blockers=Mock(return_value=['missing'])
|
||||
with self.assertRaises(ValueError):v.switch('video')
|
||||
self.assertEqual(v.scheduler.gpu_mode,'llm');v.stop_owned.assert_not_called()
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
window.VideoUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
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 mode(root,s,profiles){
|
||||
if(root.querySelector('#active-video')===document.activeElement||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><p>Modus: <strong>${e(s.mode.toUpperCase())}</strong> · ${e(s.state)} · ${e(s.error||'')}</p><label>Aktives Videoprofil<select id="active-video" ${s.mode!=='llm'||s.state==='switching'?'disabled':''}><option value="">Bitte auswählen</option>${profiles.filter(p=>p.kind==='video').map(p=>`<option value="${e(p.id)}" ${s.profile_id===p.id?'selected':''}>${e(p.name)}${p.runnable?'':' · Voraussetzungen fehlen'}</option>`).join('')}</select></label><p>${e(s.memory_policy)}</p><p>Video beendet laufende Deck-GPU-Aufträge und sperrt Chat, Bild und TTS (HTTP 503). CPU-STT bleibt verfügbar. Fremde Dienste werden nicht beendet.</p><p><a class="link" href="#video-runtime">Video-Laufzeit und Testauftrag →</a></p><p class="video-message" role="status"></p></section>`;
|
||||
if(root._html===html)return;root._html=html;root.innerHTML=html;
|
||||
async function act(path,data){root.dataset.busy='1';try{await api(path,data);root._html='';root.querySelector('.video-message').textContent='Übernommen; Status wird aktualisiert.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}}
|
||||
root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>act('video/mode',{mode:b.dataset.mode}));
|
||||
root.querySelector('#active-video').onchange=ev=>{if(ev.target.value)act('video/profile',{id:ev.target.value});};
|
||||
}
|
||||
async function runtime(){
|
||||
const view=document.querySelector('#view');if(view.querySelector('#video-runtime-page'))return;
|
||||
view.innerHTML=`<section id="video-runtime-page"><h1>LTX-Video-Laufzeit</h1><p>Eigene Python-Umgebung, gepinnte LTX-Pipelines, CUDA 12.8. Installation verändert keine Host-Treiber. Erster Ausführungsweg: Text → Video mit dem Distilled-BF16-Rezept und Disk-Streaming auf der RTX 5080.</p><p id="video-status"></p><button id="video-install">Laufzeit installieren</button><button id="video-cancel">Installation abbrechen</button><p id="video-message" role="status"></p><p><a href="#home">Modus und aktives Profil in der Übersicht auswählen →</a></p><section class="card"><h2>Video-Test</h2><p>Vorher in der Übersicht Video aktivieren. Werte überschreiben die Profilstandards für diesen Auftrag. Distilled nutzt seine feste Schrittfolge. Noch kein Bild-/Audio-Upload.</p><form id="video-test"><label>Prompt<textarea name="prompt" required maxlength="8000"></textarea></label>${[['width','Breite',512],['height','Höhe',320],['frames','Bilder (8n+1)',9],['fps','FPS',24],['seed','Seed',42]].map(([n,l,v])=>`<label>${l}<input type="number" name="${n}" value="${v}" required></label>`).join('')}<button>Video erzeugen</button></form><p id="video-job"></p><div id="video-result"></div><p>API: POST /v1/videos · GET /v1/videos/{id} · GET /v1/videos/{id}/content. Gleicher Port und API-Token wie Chat; eigenständige Deck-Video-API.</p></section></section>`;
|
||||
const root=view.firstElementChild,msg=root.querySelector('#video-message');
|
||||
async function action(path,data){try{await api(path,data);msg.textContent='Auftrag angenommen.';}catch(x){msg.textContent=x.message;}}
|
||||
root.querySelector('#video-install').onclick=()=>action('video-runtime/install',{});
|
||||
root.querySelector('#video-cancel').onclick=()=>action('video-runtime/cancel',{});
|
||||
root.querySelector('#video-test').onsubmit=ev=>{ev.preventDefault();const d=Object.fromEntries(new FormData(ev.target));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;try{const s=await api('video');root.querySelector('#video-status').textContent=s.runtime.installed?'Installiert · '+s.runtime.revision:(s.runtime.job?.phase||'Noch nicht installiert');root.querySelector('#video-install').disabled=s.runtime.installed||s.runtime.job?.state==='running';root.querySelector('#video-cancel').disabled=s.runtime.job?.state!=='running';root.querySelector('#video-job').textContent=s.job?`${s.job.state} · ${s.job.phase||''} · ${s.job.id} · ${s.job.error||''}`:'Noch kein Video-Auftrag.';if(s.job?.state==='complete'&&root.querySelector('#video-result').dataset.id!==s.job.id){root.querySelector('#video-result').dataset.id=s.job.id;root.querySelector('#video-result').innerHTML=`<video controls preload="metadata" style="max-width:100%" src="/api/v1/video/content?id=${e(s.job.id)}"></video><a href="/api/v1/video/content?id=${e(s.job.id)}" download="athena-video.mp4">Video herunterladen</a>`;}}catch(x){msg.textContent=x.message;}if(root.isConnected)setTimeout(poll,2000);}poll();
|
||||
}
|
||||
return {mode,runtime};
|
||||
})();
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Exclusive GPU mode and owned persistent LTX subprocess with asynchronous jobs."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from image_test import probe,cgroup_headroom,GIB
|
||||
from profiles import video_recipe
|
||||
|
||||
class Video:
|
||||
def __init__(self,root,profiles,runtime,scheduler,stop_owned):
|
||||
self.root=Path(root);self.profiles=profiles;self.runtime=runtime;self.scheduler=scheduler;self.stop_owned=stop_owned
|
||||
self.lock=threading.RLock();self.mode='llm';self.state='idle';self.error=None;self.process=None;self.profile=None;self.job=None;self.thread=None;self.gpu=None
|
||||
self.closing=False;self.selected=None
|
||||
if (self.root/'selection.json').exists():self.selected=json.loads((self.root/'selection.json').read_text()).get('profile_id')
|
||||
def status(self):
|
||||
with self.lock:
|
||||
if self.process and self.process.poll() is not None and self.state=='ready':self.state='failed';self.error='Video-Worker beendet; RAM-OOM oder Laufzeitfehler möglich.'
|
||||
return dict(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 RTX 5080; beide GPUs exklusiv reserviert. Gewichte werden bedarfsgerecht geladen.')
|
||||
def blockers(self,p):
|
||||
if not video_recipe(p.get('model') or {}):return ['Für diese Videovariante ist kein Worker angebunden.']
|
||||
errors=[]
|
||||
if not self.runtime.status()['installed']:errors.append('Video-Laufzeit unter Einstellungen → Laufzeiten → Video installieren.')
|
||||
for role,info in video_recipe(p['model']).items():
|
||||
try:self.profiles._component(p['model'],role,p.get('components',{}).get(role))
|
||||
except ValueError:errors.append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
|
||||
return errors
|
||||
def select(self,ident):
|
||||
with self.lock:
|
||||
if self.mode!='llm' or self.state=='switching':raise ValueError('Videoprofil nur im LLM-Modus wechseln.')
|
||||
p=next((p for p in self.profiles.status()['profiles'] if p['id']==ident and p['kind']=='video'),None)
|
||||
if not p:raise ValueError('Videoprofil nicht gefunden.')
|
||||
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
tmp=self.root/'selection.tmp';tmp.write_text(json.dumps({'profile_id':ident}));tmp.replace(self.root/'selection.json');self.selected=ident
|
||||
return self.status()
|
||||
def switch(self,mode):
|
||||
if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.')
|
||||
with self.lock:
|
||||
if self.closing:raise ValueError('Deck wird beendet.')
|
||||
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
|
||||
if mode==self.mode and self.state in ('idle','ready'):return self.status()
|
||||
profile=None
|
||||
if mode=='video':
|
||||
profile=next((p for p in self.profiles.status()['profiles'] if p['id']==self.selected),None)
|
||||
if not profile:raise ValueError('Zuerst ein aktives Videoprofil auswählen.')
|
||||
errors=self.blockers(profile)
|
||||
if errors:raise ValueError(' '.join(errors))
|
||||
# Close admission before stopping any process. Queued leases recheck this flag.
|
||||
with self.scheduler.cv:self.scheduler.gpu_mode='switching';self.scheduler.cv.notify_all()
|
||||
self.state='switching';self.error=None
|
||||
self.thread=threading.Thread(target=self._switch,args=(mode,profile),daemon=True);self.thread.start()
|
||||
return self.status()
|
||||
def _switch(self,mode,profile):
|
||||
try:
|
||||
self.stop_owned();self.stop_process()
|
||||
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':self.load(profile)
|
||||
if self.closing:raise ValueError('Deck wird beendet.')
|
||||
with self.lock:self.mode=mode;self.state='ready' if mode=='video' else 'idle'
|
||||
with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all()
|
||||
except Exception as exc:
|
||||
self.stop_process()
|
||||
with self.lock:self.mode='llm';self.state='failed';self.error=str(exc) if isinstance(exc,ValueError) else 'Video-Moduswechsel fehlgeschlagen.'
|
||||
with self.scheduler.cv:self.scheduler.gpu_mode='llm';self.scheduler.cv.notify_all()
|
||||
def stop_process(self):
|
||||
with self.lock:
|
||||
p=self.process;self.process=None;self.profile=None;self.gpu=None
|
||||
if p:
|
||||
if p.poll() is None:
|
||||
try:
|
||||
os.killpg(p.pid,signal.SIGTERM)
|
||||
try:p.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:os.killpg(p.pid,signal.SIGKILL);p.wait(timeout=5)
|
||||
except ProcessLookupError:pass
|
||||
for stream in (p.stdin,p.stdout):
|
||||
if stream:stream.close()
|
||||
def close(self):
|
||||
self.closing=True
|
||||
self.stop_process()
|
||||
if self.thread and self.thread is not threading.current_thread():self.thread.join(timeout=7)
|
||||
def read(self,p,timeout):
|
||||
deadline=time.monotonic()+timeout
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(p.stdout,selectors.EVENT_READ)
|
||||
while time.monotonic()<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=next((g for g in gpus if '5080' in g.get('name','')),None)
|
||||
if gpu is None:gpu=max(gpus,key=lambda g:g['total_mib'])
|
||||
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']))
|
||||
env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','DECK_','LLAMA_'))}
|
||||
env.update(CUDA_VISIBLE_DEVICES=gpu['uuid'],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']
|
||||
if self.closing:self.stop_process();raise ValueError('Deck wird beendet.')
|
||||
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')
|
||||
@@ -0,0 +1,50 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Private stdin/stdout worker protocol. No prompts or library logs persisted."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 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())
|
||||
def reply(value):protocol.write(json.dumps(value)+'\n')
|
||||
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)
|
||||
# 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})
|
||||
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
|
||||
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