From e851b2f3ea21d21aee1672d063ecea009cd2f8f1 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Tue, 29 Sep 2026 13:16:41 +0200
Subject: [PATCH] Select the single active video profile through endpoint
publication
---
README.md | 2 +-
endpoint-ui.js | 2 +-
endpoint.py | 8 ++++++--
profiles-ui.js | 6 +++---
test_endpoint.py | 13 +++++++++++++
test_video.py | 5 +++++
video-ui.js | 7 +++----
video.py | 2 +-
8 files changed, 33 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index b3e0f96..3f59c49 100644
--- a/README.md
+++ b/README.md
@@ -157,7 +157,7 @@ Das Rezept unterstützt keine beliebigen Quantisierungen, Comfy-INT8-Encoder ode
### 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.
+Unter **Video → Profile** genau ein Profil mit **Am API-Endpunkt freigegeben** auswählen, dann in der Übersicht **Video** drücken. Ein neuer Haken ersetzt die vorige Video-Freigabe; Abwählen entfernt sie. Die Übersicht zeigt nur den Profilnamen und die Modusschalter. Änderungen der Video-Freigabe erfordern den LLM-Modus. Deck schließt zuerst die GPU-Auftragsannahme, beendet eigene Chat-/Auto-Test-/Bild-/TTS-Arbeit und entlädt seinen llama.cpp-Prozess. Erst nach Freigabe der Reservierungen und Prüfung **aller** sichtbaren GPUs startet der eigene LTX-Worker. Fremde GPU-Prozesse werden niemals beendet; der Wechsel scheitert dann mit einer sichtbaren Meldung. **LLM** beendet den Video-Prozess einschließlich einer laufenden Generierung und öffnet die GPU-Auftragsannahme wieder. Das nächste Chat-Modell lädt bei Anfrage. Nach Deck-Neustart gilt LLM; es gibt keinen automatischen Videostart.
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.
diff --git a/endpoint-ui.js b/endpoint-ui.js
index a040775..6743bbd 100644
--- a/endpoint-ui.js
+++ b/endpoint-ui.js
@@ -20,7 +20,7 @@ window.EndpointUI=(()=>{
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.filter(p=>p.kind!=='video').map(p=>`
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 dfb756d..57f59a1 100644
--- a/endpoint.py
+++ b/endpoint.py
@@ -36,7 +36,7 @@ class Endpoint:
def rows(self):
rows=self.profiles.status()['profiles']
with self.lock:enabled=set(self.config['enabled_profiles'])
- return [dict(p,enabled=p['id'] in enabled) for p in rows]
+ return [dict(p,enabled=(p['id']==self.video.selected if self.video else False) if p['kind']=='video' else p['id'] in enabled) for p in rows]
def status(self):
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt')]:
@@ -61,8 +61,12 @@ 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']))
+ if row['kind']=='video':
+ if not self.video:raise ValueError('Video-Verwaltung nicht eingerichtet.')
+ if data['enabled']:self.video.select(row['id'])
+ elif self.video.selected==row['id']:self.video.select(None)
+ return self.status()
with self.lock:
enabled=set(self.config['enabled_profiles'])
if data['enabled']:
diff --git a/profiles-ui.js b/profiles-ui.js
index 7e5fca5..fda6f8a 100644
--- a/profiles-ui.js
+++ b/profiles-ui.js
@@ -7,9 +7,9 @@ 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=>`GESPEICHERT AUF ATHENA
${e(p.name)}
${e(p.model?.file||'Modelldatei nicht verfügbar')}
${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=>`
${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);}});
+ el('profile-list').innerHTML=rows.map(p=>`GESPEICHERT AUF ATHENA
${e(p.name)}
${e(p.model?.file||'Modelldatei nicht verfügbar')}
${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('')}
${['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.'}
${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'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'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(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'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)));
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
diff --git a/test_endpoint.py b/test_endpoint.py
index c56333b..3adc9ae 100644
--- a/test_endpoint.py
+++ b/test_endpoint.py
@@ -72,6 +72,19 @@ class EndpointTests(unittest.TestCase):
self.assertEqual(self.worker.requests[-1].get('reasoning_effort'),'max' if effort=='ultra' else effort)
for effort in ([],True,3,'invalid'):
self.assertEqual(self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')],reasoning_effort=effort))[0],400)
+ def test_video_publication_is_exclusive_and_can_be_removed(self):
+ for name in ('v1','v2'):
+ self.rows.append(dict(id=name,name=name,kind='video',runnable=True,blockers=[],parameters={},updated_at=1))
+ video=SimpleNamespace(selected=None,status=lambda:{})
+ video.select=lambda ident:setattr(video,'selected',ident)
+ self.ep.video=video
+ self.ep.enable({'id':'v1','enabled':True})
+ self.assertEqual([p['id'] for p in self.ep.rows() if p['kind']=='video' and p['enabled']],['v1'])
+ self.ep.enable({'id':'v2','enabled':True})
+ self.assertEqual([p['id'] for p in self.ep.rows() if p['kind']=='video' and p['enabled']],['v2'])
+ self.ep.enable({'id':'v1','enabled':False});self.assertEqual(video.selected,'v2')
+ self.ep.enable({'id':'v2','enabled':False});self.assertIsNone(video.selected)
+ self.ep.video=None
def test_video_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')]))
diff --git a/test_video.py b/test_video.py
index c29764d..38cc69e 100644
--- a/test_video.py
+++ b/test_video.py
@@ -27,6 +27,11 @@ class VideoTests(unittest.TestCase):
v=self.make(d);v.select('v');v.blockers=Mock(return_value=[]);v.load=Mock(side_effect=ValueError('busy'))
v.switch('video');self.wait(v)
self.assertEqual(v.status()['error'],'busy');self.assertEqual(v.scheduler.gpu_mode,'llm')
+ def test_clear_video_selection_persists(self):
+ with tempfile.TemporaryDirectory() as d:
+ v=self.make(d);v.select('v');v.select(None)
+ self.assertIsNone(v.selected);self.assertIsNone(self.make(d).selected)
+ with self.assertRaises(ValueError):v.switch('video')
def test_foreign_process_prevents_spawn(self):
with tempfile.TemporaryDirectory() as d,patch('video.probe',return_value=[{'processes':1}]),patch('video.subprocess.Popen') as spawn:
v=self.make(d)
diff --git a/video-ui.js b/video-ui.js
index bcafa8b..82ea85d 100644
--- a/video-ui.js
+++ b/video-ui.js
@@ -2,14 +2,13 @@ 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=`
`;
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});};
}
- function html(testing=false){return `${testing?'
Video testen
Profil auswählen, Video-Modus aktivieren und einen kurzen Test starten.
':'
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.
Zuerst den Video-Modus aktivieren. Werte überschreiben die Profilstandards für diesen Auftrag. Distilled nutzt seine feste Schrittfolge. Noch kein Bild-/Audio-Upload.
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.
`;}
+ function html(testing=false){return `${testing?'
Video testen
Unter Video → Profile ein Profil freigeben, dann hier den Video-Modus aktivieren und einen kurzen Test starten.
':'
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.
Zuerst den Video-Modus aktivieren. Werte überschreiben die Profilstandards für diesen Auftrag. Distilled nutzt seine feste Schrittfolge. Noch kein Bild-/Audio-Upload.
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.
`;}
async function runtime(){
const view=document.querySelector('#view');if(view.querySelector('#video-runtime-page'))return;
view.innerHTML=html();bind(view.firstElementChild);
diff --git a/video.py b/video.py
index 07f7cda..2322527 100644
--- a/video.py
+++ b/video.py
@@ -35,7 +35,7 @@ class Video:
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.')
+ if ident is not None and not p:raise ValueError('Videoprofil nicht gefunden.')
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
tmp=self.root/'selection.tmp';tmp.write_text(json.dumps({'profile_id':ident}));tmp.replace(self.root/'selection.json');self.selected=ident
return self.status()