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=>`
${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.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 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')}

${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);}}); + 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('')}
${['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'?'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=`

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.dataset.busy==='1')return; + const html=`

GPU-Modus

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

Freigegebenes Videoprofil: ${e(profiles.find(p=>p.id===s.profile_id)?.name||'Keines')} · Unter Video → Profile freigeben

${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});}; } - 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.

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

Video-Test

Zuerst den Video-Modus aktivieren. Werte überschreiben die Profilstandards für diesen Auftrag. Distilled nutzt seine feste Schrittfolge. Noch kein Bild-/Audio-Upload.

${[['width','Breite',256],['height','Höhe',256],['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.

`;} + 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.

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

Video-Test

Zuerst den Video-Modus aktivieren. Werte überschreiben die Profilstandards für diesen Auftrag. Distilled nutzt seine feste Schrittfolge. Noch kein Bild-/Audio-Upload.

${[['width','Breite',256],['height','Höhe',256],['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.

`;} 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()