diff --git a/README.md b/README.md index 1c8d266..955d224 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app.js b/app.js index 1675ed6..396e7dc 100644 --- a/app.js +++ b/app.js @@ -8,6 +8,7 @@ const metric=(title,value)=>`
${title}${value}v==null?'':``; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; +if(page==='video-runtime'){VideoUI.runtime();return;} if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;} if(page==='access'){await accessPage();return;} if(page==='network'){await networkPage();return;} diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 670a713..6276027 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 \ diff --git a/deploy/install.py b/deploy/install.py index f4df60f..2d01c76 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['deploy/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): diff --git a/deploy/video-requirements.lock b/deploy/video-requirements.lock new file mode 100644 index 0000000..86b404d --- /dev/null +++ b/deploy/video-requirements.lock @@ -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 diff --git a/endpoint-ui.js b/endpoint-ui.js index 2decd2b..a040775 100644 --- a/endpoint-ui.js +++ b/endpoint-ui.js @@ -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=`
${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}

${settings?'Endpunkt & Profile':'Dein Modell-Endpunkt'}

Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.

${settings?'

Lokaler API-Port

Der Zugriff verwendet den API-Token aus Zugang & API. Verwaltungsoberfläche und API haben getrennte Ports.

Am Endpunkt angebotene Profile

Nur explizit aktivierte, ausführbare Profile erscheinen in GET /v1/models. Aktivieren lädt noch kein Modell.

API-Routen

POST /v1/chat/completions · Text und Streaming
POST /v1/images/generations · Qwen-Image-Rezept, ein Bild als b64_json
POST /v1/audio/speech und POST /v1/audio/transcriptions · noch nicht eingerichtet (501)

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.

':''}
`; + sequence++;view.innerHTML=`
${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}

${settings?'Endpunkt & Profile':'Dein Modell-Endpunkt'}

Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.

${settings?'

Lokaler API-Port

Der Zugriff verwendet den API-Token aus Zugang & API. Verwaltungsoberfläche und API haben getrennte Ports.

Am Endpunkt angebotene Profile

Nur explizit aktivierte, ausführbare Profile erscheinen in GET /v1/models. Aktivieren lädt noch kein Modell.

API-Routen

POST /v1/chat/completions · Text und Streaming
POST /v1/images/generations · Qwen-Image-Rezept, ein Bild als b64_json
POST /v1/audio/speech und POST /v1/audio/transcriptions · noch nicht eingerichtet (501)

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.

':''}
`; 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=>`
${e(p.name)} · ${e(p.kind)} · ${p.runnable?'ausführbar':e(p.blockers.join(' '))}
`).join('')||'

Noch keine Profile angelegt. Erstelle sie in der jeweiligen Modellkategorie.

'); + put(root.querySelector('#endpoint-profiles'),s.profiles.filter(p=>p.kind!=='video').map(p=>`
${e(p.name)} · ${e(p.kind)} · ${p.runnable?'ausführbar':e(p.blockers.join(' '))}
`).join('')||'

Noch keine Profile angelegt. Erstelle sie in der jeweiligen Modellkategorie.

'); 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;} diff --git a/endpoint.py b/endpoint.py index 6a9c118..dfb756d 100644 --- a/endpoint.py +++ b/endpoint.py @@ -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 0Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
+Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
diff --git a/inference.py b/inference.py index 33fb748..3e82cc6 100644 --- a/inference.py +++ b/inference.py @@ -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{ 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=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

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

${CatalogUI.executionHTML(p.execution)}
${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${['image','video'].includes(kind)?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; - el('profile-list').insertAdjacentHTML('afterbegin',`

${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.

`); + el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

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

${CatalogUI.executionHTML(p.execution)}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${kind==='video'?'Als Videoprofil in der Übersicht auswählen →':``}${['image','video'].includes(kind)?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + el('profile-list').insertAdjacentHTML('afterbegin',`

${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.

`); 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))); diff --git a/profiles.py b/profiles.py index 2d67a48..216bda7 100644 --- a/profiles.py +++ b/profiles.py @@ -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): diff --git a/server.py b/server.py index f3d4d83..c31bd36 100644 --- a/server.py +++ b/server.py @@ -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() diff --git a/studio.js b/studio.js index dd707ca..e1c6056 100644 --- a/studio.js +++ b/studio.js @@ -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 `
EINSTELLUNGEN

Laufzeiten

Laufzeiten werden einmal eingerichtet und für kompatible Modelle wiederverwendet. Entdecken, Bibliothek und Profile zeigen den passenden Einrichtungsweg; unbekannte Modelle werden als noch nicht unterstützt gekennzeichnet. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

Spracherkennung

Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.

STT einrichten →

Text-to-Speech

Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.

TTS einrichten →

llama.cpp

Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →

Docker

Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.

Docker öffnen →
`;} + function runtimeOverview(){return `
EINSTELLUNGEN

Laufzeiten

Laufzeiten werden einmal eingerichtet und für kompatible Modelle wiederverwendet. Entdecken, Bibliothek und Profile zeigen den passenden Einrichtungsweg; unbekannte Modelle werden als noch nicht unterstützt gekennzeichnet. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

Video

LTX-Pipelines · eigener Worker mit exklusivem GPU-Modus.

Video-Laufzeit und Test →

Spracherkennung

Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.

STT einrichten →

Text-to-Speech

Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.

TTS einrichten →

llama.cpp

Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →

Docker

Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.

Docker öffnen →
`;} function imageRuntime(){return `
EINSTELLUNGEN / LAUFZEITEN

Bildgenerierung

ComfyUI + ComfyUI-GGUF

Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF und das unterstützte FLUX.2-Klein-9B-Modell. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.

Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Bei großen FLUX-Gewichten wird zusätzlich System-RAM genutzt. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.

Installationsstatus wird geprüft …

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} function openProfile(page,modelId){render(page,true,modelId);} return {render,openProfile}; diff --git a/test_endpoint.py b/test_endpoint.py index 77f3353..c56333b 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -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={})) diff --git a/test_video.py b/test_video.py new file mode 100644 index 0000000..c29764d --- /dev/null +++ b/test_video.py @@ -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() diff --git a/video-ui.js b/video-ui.js new file mode 100644 index 0000000..b06f4cc --- /dev/null +++ b/video-ui.js @@ -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=`

GPU-Modus

Modus: ${e(s.mode.toUpperCase())} · ${e(s.state)} · ${e(s.error||'')}

${e(s.memory_policy)}

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.

Video-Laufzeit und Testauftrag →

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

LTX-Video-Laufzeit

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.

Modus und aktives Profil in der Übersicht auswählen →

Video-Test

Vorher in der Übersicht Video aktivieren. Werte überschreiben die Profilstandards für diesen Auftrag. Distilled nutzt seine feste Schrittfolge. Noch kein Bild-/Audio-Upload.

${[['width','Breite',512],['height','Höhe',320],['frames','Bilder (8n+1)',9],['fps','FPS',24],['seed','Seed',42]].map(([n,l,v])=>``).join('')}

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.

`; + 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 herunterladen`;}}catch(x){msg.textContent=x.message;}if(root.isConnected)setTimeout(poll,2000);}poll(); + } + return {mode,runtime}; +})(); diff --git a/video.py b/video.py new file mode 100644 index 0000000..07f7cda --- /dev/null +++ b/video.py @@ -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()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') diff --git a/video_runtime.py b/video_runtime.py new file mode 100644 index 0000000..d75ddbc --- /dev/null +++ b/video_runtime.py @@ -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() diff --git a/video_worker.py b/video_worker.py new file mode 100644 index 0000000..823e9ea --- /dev/null +++ b/video_worker.py @@ -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.'})