diff --git a/README.md b/README.md index a05f958..3a42b96 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Das Rezept unterstützt keine beliebigen Quantisierungen, Comfy-INT8-Encoder ode 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. +Die Video-Laufzeit lässt sich unter Einstellungen → Laufzeiten → Video installieren/abbrechen. Sie verwendet eine eigene venv, den in `video_runtime.py` gepinnten offiziellen LTX-Commit sowie `deploy/video-requirements.lock` (PyTorch CUDA 12.8, keine Host-Treiberänderung). Die Module und Lockdatei gehören zu beiden Installationspaketen. Der erste Adapter unterstützt das dokumentierte LTX-2.5-Distilled-BF16-Komponentenrezept. Beide GPUs sind für Deck exklusiv reserviert. Standardmäßig erfolgt die Berechnung auf der RTX 5080 mit Disk-Streaming; im Videoprofil kann der Textencoder separat der RTX 3060 zugeordnet werden. „Bereit“ heißt: persistenter Worker, Pipeline und Komponentenmetadaten vorbereitet. BF16-Gewichte werden bedarfsgerecht gestreamt, nicht vollständig im VRAM gehalten. Eine freie GPU garantiert keinen OOM-freien Auftrag. Eigene Video-API auf demselben API-Port und mit demselben Bearer-Token wie Chat (keine vollständige OpenAI-Videos-Kompatibilitätszusage): @@ -177,3 +177,11 @@ Video-Build-Voraussetzung: Python-Entwicklungsheader passend zur verwendeten Pyt Verifiziert auf Athena am 29.09.2026: offizielles LTX-2.5-Distilled-BF16-Profil mit zugeordneten Komponenten vorbereitet; exklusiver Modus sperrt Chat mit HTTP 503 / `video_mode_active`; synthetischer POST-Videoauftrag liefert HTTP 202, Statusabfrage und MP4-Download funktionieren. Ergebnis: 256×256, 9 dekodierbare Bilder, 24 FPS und Audiospur (12.802 Bytes). Nach Rückwechsel auf LLM war der Video-Prozess beendet und der GPU-Speicher wieder auf Treibergrundbelegung (3060: 1 MiB, 5080: 6 MiB). Der anfängliche Triton-Kompilierfehler wurde durch Python-Entwicklungsheader im Deck-Image behoben. Kein Produktivdienst wurde für diese Prüfung verändert. Dieser kleine Funktionstest ist keine Speicher- oder Geschwindigkeitsgarantie für größere Auflösungen/Längen. Video-Oberfläche: Moduswechsel erfolgen ausschließlich in der Übersicht. Eine laufende Ladeanzeige zeigt Phase und verstrichene Zeit. Video → Testen meldet einen falschen Modus mit Link zur Übersicht und bestätigt das Absenden sofort. Video → Laufend zeigt ausschließlich Worker, aktuellen Auftrag und Ergebnis. Installationsaktionen liegen unter Einstellungen → Laufzeiten → Video. Die Auftragsanzeige wird alle 1,5 Sekunden aktualisiert; der Worker liefert Phasen, jedoch keine belastbaren Prozentwerte. + +### Video: Geräte pro Komponente + +Unter **Video → Profile → Bearbeiten → Komponenten und Geräte** die GPU für Videopipeline und Textencoder auswählen. Gespeichert werden stabile GPU-UUIDs (`video_device`, `text_encoder_device`), keine flüchtigen CUDA-Indizes. Alte Profile nutzen weiterhin automatisch bevorzugt die RTX 5080 und denselben Ort für den Textencoder. Auswahl einer nicht vorhandenen GPU führt beim Laden zu einem klaren Fehler statt stiller Umleitung. Änderungen werden erst nach Rückwechsel auf LLM und erneutem Wechsel auf Video übernommen. + +Der gepinnte LTX-Adapter unterstützt einen separaten CUDA-PromptEncoder mit Disk-Streaming. Seine Video-/Audio-Embeddings und Attention-Maske werden vor der Diffusion auf die Pipeline-GPU übertragen. Video-VAE, Audio-VAE und Spatial-Upsampler bleiben an die Videopipeline gebunden. CPU-Ausführung sowie unabhängige Decoder-/Upsampler-Geräte sind derzeit ausdrücklich nicht freigeschaltet. Fremde GPU-Prozesse bleiben geschützt; Video bleibt exklusiv. + +Speicherhinweise im Editor vergleichen die tatsächliche Dateigröße mit GPU-Kapazität und verfügbarer VRAM-Momentaufnahme. Sie sind keine Spitzenbedarfsmessung und keine Passgarantie. BF16-Dateien oberhalb der Kapazität benötigen Streaming; Auflösung, Bildanzahl und Aktivierungen verursachen weiteren Bedarf. Eine zweite GPU beschleunigt die sequenzielle Pipeline nicht automatisch. diff --git a/profiles-ui.js b/profiles-ui.js index fda6f8a..21b445f 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -1,13 +1,13 @@ window.ProfilesUI=(()=>{ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - const labels={gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'}; + const labels={video_device:'Videopipeline',text_encoder_device:'Textencoder-Gerät',gpu_reserve_mode:'GPU-Reservemodus',gpu_reserve_mib:'GPU-Reserve (MiB)',vision_projector:'Vision-Projektor',vision_device:'Projektor-Gerät',gpu_offload:'GPU-Ausführung',mtp:'MTP',mtp_tokens:'MTP: maximale Draft-Token',mtp_min_p:'MTP: Mindestwahrscheinlichkeit',temperature:'Temperature',top_p:'Top-p',top_k:'Top-k',gpu_devices:'GPUs (in Reihenfolge)',split_mode:'GPU-Split',tensor_split:'GPU-Verteilung',context:'Gesamtes Kontextbudget (Token)',slots:'Parallele Slots',threads:'CPU-Threads',batch:'Batch-Größe',ubatch:'Microbatch-Größe',width:'Breite (Pixel)',height:'Höhe (Pixel)',steps:'Schritte',seed:'Seed (−1 = zufällig)',guidance:'Guidance / CFG',speed:'Sprechgeschwindigkeit',frames:'Bildanzahl',fps:'Bilder pro Sekunde'}; const html=()=>'

Deine Profile

Auf Athena gespeichert. Eine Modelldatei kann mehrere Profile mit unterschiedlichen Parametern haben.

'; async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;} function bind(kind,modelId){ - const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],panelSequence=0; + const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],entries=[],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('')}
${['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.'}

`; + async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;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(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):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;}}); @@ -45,7 +45,7 @@ window.ProfilesUI=(()=>{ const mmproj=f=>/(?:^|[\/_-])mmproj(?:[\/_.-]|$)/i.test(f.name)&&f.name.toLowerCase().endsWith('.gguf'); async function refreshLibrary(){ const c=await api('catalog');if(!box.isConnected)return; - const selected=select.value;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf')); + const selected=select.value;entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf')); select.innerHTML=''+projectors.map(x=>``).join('');select.value=selected; return c; } @@ -90,16 +90,40 @@ window.ProfilesUI=(()=>{ } form.elements.model_id.addEventListener('change',check);check(); } + function videoDevices(data){ + const params=data.parameters; + const options=(key,fallback,title)=>``; + return `

Komponenten und Geräte

${options('video_device','auto','Videomodell / Diffusion')}${options('text_encoder_device','same','Textencoder')}

Video-VAE, Audio-VAE und Spatial-Upsampler laufen derzeit auf der GPU der Videopipeline. Unabhängige Zuordnung dieser Komponenten ist noch nicht unterstützt.

Disk-Streaming bleibt aktiv. Dateigröße ist keine VRAM-Messung: Aktivierungen, Auflösung und Bildanzahl benötigen zusätzlichen Speicher. Änderungen gelten nach erneutem Wechsel in den Videomodus.

`; + } + function bindVideoDevices(data){ + const form=el('profile-form'),target=el('video-device-advice'); + function refresh(){ + const main=models.find(m=>m.id===form.elements.model_id.value),encoder=main?.id===data.model_id?entries.find(m=>m.id===data.components?.text_encoder):null; + const primary=form.elements.video_device.value==='auto'?(gpus.find(g=>g.name.includes('5080'))||[...gpus].sort((a,b)=>(b.total_mib||0)-(a.total_mib||0))[0]):gpus.find(g=>g.uuid===form.elements.video_device.value); + const secondary=form.elements.text_encoder_device.value==='same'?primary:gpus.find(g=>g.uuid===form.elements.text_encoder_device.value); + function assessment(label,file,gpu){ + if(!file)return `

${label}: Komponente noch nicht zugeordnet; Größenvergleich nicht möglich.

`; + if(!gpu)return `

${label}: ausgewählte GPU nicht verfügbar. Hardware-Verbindung prüfen.

`; + const size=file.size/1024**3,total=gpu.total_mib/1024,free=Number.isFinite(gpu.used_mib)&&Number.isFinite(gpu.total_mib)?(gpu.total_mib-gpu.used_mib)/1024:null; + const valid=Number.isFinite(size)&&size>0&&Number.isFinite(total)&&total>0; + return `

${label} → ${e(gpu.name)}
Datei: ${valid?size.toFixed(2)+' GiB':'unbekannt'} · VRAM gesamt: ${Number.isFinite(total)?total.toFixed(1)+' GiB':'unbekannt'} · aktuell frei: ${free===null?'unbekannt':free.toFixed(1)+' GiB'}. ${!valid?'Speichereignung unbekannt.':size>total?'Datei größer als gesamter VRAM: vollständiges Laden in unverändertem BF16 ist nicht vorgesehen; Streaming nötig.':free!==null&&size>free?'Datei größer als aktuell freier VRAM. Laufende Belegung kann sich beim Moduswechsel ändern.':'Dateigröße unter VRAM-Kapazität; tatsächlicher Spitzenbedarf noch nicht gemessen.'}

`; + } + target.innerHTML='

Empfehlung: RTX 5080 für die Videopipeline. Textencoder zunächst auf derselben GPU lassen; RTX 3060 ist eine optionale getrennte Ausführung mit Streaming, keine Zusage für mehr Geschwindigkeit oder vollständige VRAM-Residenz.

'+assessment('Videomodell',main,primary)+assessment('Textencoder',encoder,secondary)+'

Hardwarewerte sind eine Momentaufnahme beim Öffnen der Profilansicht. Ein erfolgreicher Test mit deiner Auflösung bleibt erforderlich.

'; + } + for(const key of ['video_device','text_encoder_device','model_id'])form.elements[key].addEventListener('change',refresh); + refresh(); + } function editor(p=null,id=null){ panelSequence++; if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))}; if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;} - el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='chat'?gpuEditor(data.parameters)+`

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

`; + el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

`; if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));} if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();} + if(kind==='video')bindVideoDevices(data); el('profile-close').onclick=()=>el('profile-editor').replaceChildren(); el('profile-form').onsubmit=async event=>{event.preventDefault();const form=event.target,values=new FormData(form),button=form.querySelector('button:not([type=button])');button.disabled=true; - try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.gpu_offload=values.get('gpu_offload');if(values.get('gpu_second')&&!values.get('gpu_first'))throw Error('Bitte zuerst die erste GPU auswählen.');if((data.parameters.gpu_devices||[]).length>2)throw Error('Dieses Profil enthält mehr als zwei GPUs; der Editor unterstützt derzeit zwei.');parameters.gpu_devices=[values.get('gpu_first'),values.get('gpu_second')].filter(Boolean);parameters.gpu_reserve_mode=values.get('gpu_reserve_mode');parameters.gpu_reserve_mib=parameters.gpu_reserve_mode==='manual'?Object.fromEntries([['gpu_first','reserve_first'],['gpu_second','reserve_second']].filter(([g])=>values.get(g)).map(([g,r])=>[values.get(g),Number(values.get(r))])):{};parameters.split_mode=values.get('split_mode');const raw=String(values.get('tensor_split')).trim();parameters.tensor_split=raw?raw.split(',').map(x=>x.trim()?Number(x):NaN):[];}await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');} + try{const parameters=Object.fromEntries(Object.keys(schema).map(k=>[k,Number(values.get(k))]));if(kind==='video'){parameters.video_device=values.get('video_device');parameters.text_encoder_device=values.get('text_encoder_device');}if(kind==='chat'){parameters.vision_projector=values.get('vision_projector')||null;parameters.vision_device=values.get('vision_device');parameters.mtp=values.has('mtp');parameters.gpu_offload=values.get('gpu_offload');if(values.get('gpu_second')&&!values.get('gpu_first'))throw Error('Bitte zuerst die erste GPU auswählen.');if((data.parameters.gpu_devices||[]).length>2)throw Error('Dieses Profil enthält mehr als zwei GPUs; der Editor unterstützt derzeit zwei.');parameters.gpu_devices=[values.get('gpu_first'),values.get('gpu_second')].filter(Boolean);parameters.gpu_reserve_mode=values.get('gpu_reserve_mode');parameters.gpu_reserve_mib=parameters.gpu_reserve_mode==='manual'?Object.fromEntries([['gpu_first','reserve_first'],['gpu_second','reserve_second']].filter(([g])=>values.get(g)).map(([g,r])=>[values.get(g),Number(values.get(r))])):{};parameters.split_mode=values.get('split_mode');const raw=String(values.get('tensor_split')).trim();parameters.tensor_split=raw?raw.split(',').map(x=>x.trim()?Number(x):NaN):[];}await api('profiles/save',{id:data.id,revision:data.revision,name:values.get('name'),kind,model_id:values.get('model_id'),parameters});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Profil auf Athena gespeichert. Kein Modell gestartet.');} catch(error){if(root.isConnected)el('profile-error').textContent=error.message;}finally{button.disabled=false;} }; el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'}); diff --git a/profiles.py b/profiles.py index 216bda7..8c9de88 100644 --- a/profiles.py +++ b/profiles.py @@ -13,6 +13,15 @@ SCHEMAS={ 'stt':{},'music':{},'voice':{}, 'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)} } +VIDEO_DEVICE_DEFAULTS={'video_device':'auto','text_encoder_device':'same'} +def video_parameters(params): + params={**VIDEO_DEVICE_DEFAULTS,**params} + for key in VIDEO_DEVICE_DEFAULTS: + value=params[key] + if not isinstance(value,str) or not (value in (('auto',) if key=='video_device' else ('same',)) or re.fullmatch(r'GPU-[a-zA-Z0-9-]+',value)): + raise ValueError('Video-Gerät muss eine GPU-UUID sein; CPU wird im Disk-Streaming-Adapter noch nicht unterstützt.') + return params + CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu'} CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05} @@ -75,6 +84,7 @@ class Profiles: with self.lock: rows=json.loads(json.dumps(self.rows)) for p in rows: + if p['kind']=='video':p['parameters']={**VIDEO_DEVICE_DEFAULTS,**p['parameters']} if p['kind']=='chat':p['parameters']={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**p['parameters']} try: p['model']=self.catalog.entry(p['model_id']) @@ -110,7 +120,8 @@ class Profiles: if params['vision_projector']: projector=self.catalog.entry(params['vision_projector']) if projector.get('role')!='vision_projector' or not projector['file'].endswith('.gguf'):raise ValueError('Eine heruntergeladene mmproj-GGUF-Datei auswählen.') - if not isinstance(params,dict) or set(params)!=(set(SCHEMAS[kind]) | (set(CHAT_GPU_DEFAULTS) if kind=='chat' else set())):raise ValueError('Unvollständige oder unbekannte Profilparameter.') + if kind=='video' and isinstance(params,dict):params=video_parameters(params) + if not isinstance(params,dict) or set(params)!=(set(SCHEMAS[kind]) | (set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(VIDEO_DEVICE_DEFAULTS) if kind=='video' else set())):raise ValueError('Unvollständige oder unbekannte Profilparameter.') for key,(lo,hi,_) in SCHEMAS[kind].items(): value=params[key] floating=key in ('guidance','speed','temperature','top_p','mtp_min_p') diff --git a/test_video_devices.py b/test_video_devices.py new file mode 100644 index 0000000..17504be --- /dev/null +++ b/test_video_devices.py @@ -0,0 +1,39 @@ +import tempfile +import unittest +from collections import namedtuple +from contextlib import nullcontext +from pathlib import Path +from unittest.mock import Mock,patch +from profiles import Profiles,SCHEMAS,video_parameters +from video import video_devices +from video_worker import EncoderOnDevice + +class VideoDeviceTests(unittest.TestCase): + def test_selection_is_by_uuid_not_physical_index(self): + cards=[dict(uuid='GPU-second',name='RTX 3060',total_mib=12000),dict(uuid='GPU-first',name='RTX 5080',total_mib=16000)] + main,encoder,ordered=video_devices({'parameters':{'text_encoder_device':'GPU-second'}},cards) + self.assertEqual([g['uuid'] for g in ordered],['GPU-first','GPU-second']) + self.assertEqual(video_devices({'parameters':{}},cards)[2],[main]) + self.assertEqual(video_devices({'parameters':{'video_device':'GPU-second'}},cards)[2],[encoder]) + with self.assertRaises(ValueError):video_devices({'parameters':{'text_encoder_device':'GPU-missing'}},cards) + def test_parameters_persist_and_old_profiles_default_to_same_gpu(self): + with tempfile.TemporaryDirectory() as d: + catalog=Mock();catalog.entry.return_value=dict(id='m',kind='video',repo='example/repo',file='model.safetensors',profile_eligible=True) + store=Profiles(Path(d)/'profiles.json',catalog) + params={k:v[2] for k,v in SCHEMAS['video'].items()} + row=store.save(dict(id=None,revision=0,name='video-test',kind='video',model_id='m',parameters=params)) + self.assertEqual(row['parameters']['text_encoder_device'],'same') + row['parameters'].update(video_device='GPU-first',text_encoder_device='GPU-second') + store.save({k:row[k] for k in ('id','revision','name','kind','model_id','parameters')}) + self.assertEqual(Profiles(Path(d)/'profiles.json',catalog).rows[0]['parameters']['text_encoder_device'],'GPU-second') + for value in ['cpu','cuda:1',None,{},'GPU-abc;bad']: + with self.assertRaises(ValueError):video_parameters({'text_encoder_device':value}) + def test_encoder_moves_conditioning_back_and_preserves_optional_audio(self): + result=namedtuple('Embedding','video_encoding audio_encoding attention_mask') + video,mask=Mock(),Mock();encoder=Mock(return_value=[result(video,None,mask)]) + torch=Mock();torch.cuda.device.side_effect=lambda device:nullcontext() + with patch.dict('sys.modules',{'torch':torch}): + output=EncoderOnDevice(encoder,'cuda:1','cuda:0')(['synthetic']) + torch.cuda.device.assert_called_once_with('cuda:1') + video.to.assert_called_once_with('cuda:0');mask.to.assert_called_once_with('cuda:0') + self.assertIsNone(output[0].audio_encoding) diff --git a/video-ui.js b/video-ui.js index 7ac668a..2f390ad 100644 --- a/video-ui.js +++ b/video-ui.js @@ -6,7 +6,7 @@ window.VideoUI=(()=>{ async function api(path,data){const r=await fetch('/api/v1/'+path,data!==undefined?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{}),s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;} function workerState(s){ if(s.state==='switching')return busy(`Wechsel zu ${labels[s.target_mode]||'anderem Modus'}`,`${s.switch_phase||'Laufende Aufträge beenden und Speicher freigeben'}${s.switch_started_at?' · '+elapsed(s.switch_started_at):''}`); - return `
${e(labels[s.state]||s.state)}

GPU-Modus: ${e(labels[s.mode]||s.mode)}${s.profile_name?' · Geladen: '+e(s.profile_name):''}

${s.error?`

${e(s.error)}

`:''}
`; + return `
${e(labels[s.state]||s.state)}

GPU-Modus: ${e(labels[s.mode]||s.mode)}${s.profile_name?' · Vorbereitet: '+e(s.profile_name):''}

${s.profile_name&&s.devices?`

Videopipeline: ${e(s.devices.video||'unbekannt')} · Textencoder: ${e(s.devices.text_encoder||'unbekannt')}

`:''}${s.error?`

${e(s.error)}

`:''}
`; } function jobState(job){ if(!job)return '

Noch kein Video-Test gestartet.

'; @@ -28,7 +28,7 @@ window.VideoUI=(()=>{ } function html(view='test'){ const test=view==='test',runtime=view==='runtime'; - return `

${runtime?'LTX-Video-Laufzeit':test?'Video testen':'Video · Laufend'}

${runtime?'

Eigene LTX-Python-Umgebung mit CUDA und Disk-Streaming auf der RTX 5080.

':'
'}
${test?`

Kurzer Text-zu-Video-Test mit dem freigegebenen Profil. Werte gelten nur für diesen Auftrag. Noch kein Bild-/Audio-Upload.

${[['width','Breite',256,256,1920,64],['height','Höhe',256,256,1088,64],['frames','Bilder (8n+1)',9,9,241,8],['fps','FPS',24,1,60,1],['seed','Seed',42,-1,2147483647,1]].map(([n,l,v,min,max,step])=>``).join('')}
`:''}${!runtime?'

Auftragsstatus

':'

Video → Testen bietet einen kurzen Funktionstest; Video → Laufend zeigt den Worker und den aktuellen Auftrag.

'}
`; + return `

${runtime?'LTX-Video-Laufzeit':test?'Video testen':'Video · Laufend'}

${runtime?'

Eigene LTX-Python-Umgebung mit CUDA und Disk-Streaming; Gerätezuordnung im Videoprofil.

':'
'}
${test?`

Kurzer Text-zu-Video-Test mit dem freigegebenen Profil. Werte gelten nur für diesen Auftrag. Noch kein Bild-/Audio-Upload.

${[['width','Breite',256,256,1920,64],['height','Höhe',256,256,1088,64],['frames','Bilder (8n+1)',9,9,241,8],['fps','FPS',24,1,60,1],['seed','Seed',42,-1,2147483647,1]].map(([n,l,v,min,max,step])=>``).join('')}
`:''}${!runtime?'

Auftragsstatus

':'

Video → Testen bietet einen kurzen Funktionstest; Video → Laufend zeigt den Worker und den aktuellen Auftrag.

'}
`; } function runtime(){const view=document.querySelector('#view');if(view.querySelector('[data-video-view="runtime"]'))return;view.innerHTML=html('runtime');bind(view.firstElementChild);} function bind(root=document.querySelector('#video-runtime-page')){ diff --git a/video.py b/video.py index 3b44075..d8e0f4e 100644 --- a/video.py +++ b/video.py @@ -11,18 +11,30 @@ import threading import time import uuid from image_test import probe,cgroup_headroom,GIB -from profiles import video_recipe +from profiles import video_recipe,video_parameters + +def video_devices(profile,gpus): + if not gpus:raise ValueError('Keine Video-GPU verfügbar.') + params=video_parameters(profile.get('parameters',{})) + def selected(value): + found=next((g for g in gpus if g['uuid']==value),None) + if not found:raise ValueError('Im Videoprofil ausgewählte GPU nicht verfügbar: '+value) + return found + gpu=(next((g for g in gpus if '5080' in g.get('name','')),None) or max(gpus,key=lambda g:g['total_mib'])) if params['video_device']=='auto' else selected(params['video_device']) + encoder=gpu if params['text_encoder_device']=='same' else selected(params['text_encoder_device']) + devices=[gpu] if encoder['uuid']==gpu['uuid'] else [gpu,encoder] + return gpu,encoder,devices class Video: 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.lock=threading.RLock();self.mode='llm';self.state='idle';self.error=None;self.process=None;self.profile=None;self.job=None;self.thread=None;self.gpu=None;self.device_names={} self.switch_phase=None;self.switch_started_at=None;self.target_mode=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(target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,mode=self.mode,state=self.state,error=self.error,profile_name=self.profile['name'] if self.profile else None,profile_id=self.selected,loaded_profile_id=self.profile['id'] if self.profile else None,gpu=self.gpu,job=dict(self.job) if self.job else None,runtime=self.runtime.status(),memory_policy='Disk-Streaming auf RTX 5080; beide GPUs exklusiv reserviert. Gewichte werden bedarfsgerecht geladen.') + return dict(devices=dict(self.device_names),target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,mode=self.mode,state=self.state,error=self.error,profile_name=self.profile['name'] if self.profile else None,profile_id=self.selected,loaded_profile_id=self.profile['id'] if self.profile else None,gpu=self.gpu,job=dict(self.job) if self.job else None,runtime=self.runtime.status(),memory_policy='Disk-Streaming auf den im Profil gewählten GPUs; GPUs exklusiv reserviert. Gewichte werden bedarfsgerecht geladen.') def blockers(self,p): if not video_recipe(p.get('model') or {}):return ['Für diese Videovariante ist kein Worker angebunden.'] errors=[] @@ -119,8 +131,7 @@ class Video: 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']) + gpu,encoder,devices=video_devices(profile,gpus) if gpu['free_mib']<12000:raise ValueError('Mindestens 12 GiB freier GPU-Speicher für den Video-Test erforderlich.') available=next((int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('MemAvailable:')),0) if available<10*GIB:raise ValueError('Mindestens 10 GiB verfügbarer Host-RAM erforderlich.') @@ -129,12 +140,14 @@ class Video: def path(entry):return str((self.profiles.catalog.root/entry['id']/('model'+Path(entry['file']).suffix)).resolve()) components={role:self.profiles._component(profile['model'],role,profile['components'][role]) for role in video_recipe(profile['model'])} config=dict(paths=dict(transformer_path=path(profile['model']),text_encoder_path=path(components['text_encoder']),video_vae_path=path(components['video_vae']),audio_vae_path=path(components['audio_vae'])),spatial_upsampler=path(components['spatial_upsampler'])) + config['text_encoder_device']='cuda:0' if len(devices)==1 else 'cuda:1' + config['device_names']={'video':gpu['name'],'text_encoder':encoder['name']} env={k:v for k,v in os.environ.items() if not k.startswith(('HF_','DECK_','LLAMA_'))} - env.update(CUDA_VISIBLE_DEVICES=gpu['uuid'],HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='2',TOKENIZERS_PARALLELISM='false') + env.update(CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in devices),HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',OMP_NUM_THREADS='2',TOKENIZERS_PARALLELISM='false') self.root.mkdir(parents=True,exist_ok=True,mode=0o700);env.update(HOME=str(self.root),TMPDIR=str(self.root)) python,_=self.runtime.paths() p=subprocess.Popen([str(python),str(Path(__file__).with_name('video_worker.py'))],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,bufsize=0,start_new_session=True,env=env) - with self.lock:self.process=p;self.gpu=gpu['uuid'] + with self.lock:self.process=p;self.gpu=gpu['uuid'];self.device_names=dict(config['device_names']) if self.closing:self.stop_process();raise ValueError('Deck wird beendet.') with self.lock:self.switch_phase='Video-Worker laden und Komponenten prüfen' p.stdin.write((json.dumps(config)+'\n').encode());p.stdin.flush();result=self.read(p,180) diff --git a/video_worker.py b/video_worker.py index 823e9ea..247599a 100644 --- a/video_worker.py +++ b/video_worker.py @@ -3,10 +3,16 @@ 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') + +class EncoderOnDevice: + """Encode on a separate GPU; move all returned conditioning to the diffusion GPU.""" + def __init__(self,encoder,source,target):self.encoder=encoder;self.source=source;self.target=target + def __call__(self,*args,**kwargs): + import torch + with torch.cuda.device(self.source):outputs=self.encoder(*args,**kwargs) + return [type(output)(*(value.to(self.target) if value is not None else None for value in output)) for output in outputs] + def main(): import torch from ltx_pipelines.distilled import DistilledPipeline @@ -17,6 +23,11 @@ def main(): config=json.loads(sys.stdin.readline()) paths=ModelPaths.from_split(**config['paths']) pipeline=DistilledPipeline(model_paths=paths,spatial_upsampler_path=config['spatial_upsampler'],loras=(),device=torch.device('cuda:0'),offload_mode=OffloadMode.DISK) + encoder_device=torch.device(config.get('text_encoder_device','cuda:0')) + if encoder_device!=pipeline.device: + from ltx_pipelines.utils.blocks import PromptEncoder + encoder=PromptEncoder(paths,torch.bfloat16,encoder_device,offload_mode=OffloadMode.DISK) + pipeline.prompt_encoder=EncoderOnDevice(encoder,encoder_device,pipeline.device) # Validate all split pack metadata without materializing BF16 weights in RAM. from ltx_core.text_encoders.gemma.gemma_assets import GemmaAssets GemmaAssets.load(config['paths']['text_encoder_path']) @@ -24,7 +35,7 @@ def main(): 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}) + reply({'state':'progress','phase':self.label+(' · '+config.get('device_names',{}).get('text_encoder' if self.label.startswith('Textencoder') else 'video',''))}) return self.target(*args,**kwargs) for attr,label in [('prompt_encoder','Textencoder · Prompt verarbeiten'),('stage','Videogewichte laden und Diffusion berechnen'),('upsampler','Video hochskalieren'),('video_decoder','Videobilder dekodieren'),('audio_decoder','Audiospur dekodieren')]:setattr(pipeline,attr,Phase(getattr(pipeline,attr),label)) reply({'state':'ready'}) @@ -37,5 +48,9 @@ def main(): 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.'}) +if __name__=='__main__': + # Keep stdout exclusively for bounded protocol replies, discard library output. + protocol=os.fdopen(os.dup(sys.stdout.fileno()),'w',buffering=1) + os.dup2(os.open(os.devnull,os.O_WRONLY),sys.stdout.fileno()) + try:main() + except Exception:reply({'state':'failed','error':'LTX-Profil konnte nicht vorbereitet werden. Komponenten oder Laufzeit inkompatibel.'})