Sort newest Hub models and run image encoder on second GPU
This commit is contained in:
@@ -127,6 +127,14 @@ Neue sitzungsgeschützte API-Routen: `GET /api/v1/image-runtime`,
|
|||||||
POST benötigt wie die übrigen Verwaltungsaktionen `X-Athena-Deck: 1`.
|
POST benötigt wie die übrigen Verwaltungsaktionen `X-Athena-Deck: 1`.
|
||||||
|
|
||||||
Die Hardware-Seite zeigt GPU-Rechenlast und VRAM-Belegung mit separaten Balken.
|
Die Hardware-Seite zeigt GPU-Rechenlast und VRAM-Belegung mit separaten Balken.
|
||||||
Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder
|
Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder arbeitet auf der RTX 3060, Bildmodell und VAE auf der
|
||||||
arbeitet auf CPU/System-RAM; GPU-Last kann daher während eines Auftrags null
|
RTX 5080. CPU/System-RAM bleiben für Laden und Offload beteiligt. Beide GPUs
|
||||||
sein. Nach Abschluss beendet Deck den eigenen Worker und gibt dessen VRAM frei.
|
müssen frei sein; es gibt keinen stillen CPU-Fallback. GPU-Last kann während
|
||||||
|
Vorbereitung und Laden null sein. Nach Abschluss beendet Deck den eigenen Worker und gibt dessen VRAM frei.
|
||||||
|
|
||||||
|
Unter **Entdecken** steht zusätzlich **Neu hinzugefügt** zur Verfügung. Die
|
||||||
|
Abfrage sortiert bereits auf Hugging Face nach dem Erstellungsdatum des
|
||||||
|
Repositories absteigend; sie sortiert nicht nur die beliebtesten 20 Treffer um.
|
||||||
|
Name A–Z sortiert weiterhin die angezeigte Treffermenge.
|
||||||
|
|
||||||
|
Prüfung vorhandener Studios: [Native Anwendungen und Docker](docs/NATIVE_APPLICATION_AUDIT.md).
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if(['chat','image','audio','video','runtime','runtimes','image-runtime'].include
|
|||||||
if(page==='access'){await accessPage();return;}
|
if(page==='access'){await accessPage();return;}
|
||||||
if(page==='network'){await networkPage();return;}
|
if(page==='network'){await networkPage();return;}
|
||||||
if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
|
if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
|
||||||
}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden. GPU-Rechenlast und belegter VRAM werden getrennt dargestellt.');if(!h.available)html+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}</div><small>GPU-Rechenlast</small>${bar(g.percent)}<small>VRAM-Belegung</small>${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">Die Werte gelten für den gesamten Server einschließlich anderer Dienste. Beim Bildtest läuft der Textencoder auf der CPU; 0 % GPU-Rechenlast bedeutet nicht, dass kein Modell geladen ist. Nach dem Test wird der eigene Worker entladen.<br>${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;}
|
}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden. GPU-Rechenlast und belegter VRAM werden getrennt dargestellt.');if(!h.available)html+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}</div><small>GPU-Rechenlast</small>${bar(g.percent)}<small>VRAM-Belegung</small>${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">Die Werte gelten für den gesamten Server einschließlich anderer Dienste. Beim Bildtest laufen Textencoder auf der RTX 3060 und Bildmodell auf der RTX 5080; 0 % GPU-Rechenlast bedeutet nicht, dass kein Modell geladen ist. Nach dem Test wird der eigene Worker entladen.<br>${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;}
|
||||||
}else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`<section class="empty"><div class="symbol">+</div><h2>Platz für den nächsten Schritt.</h2><p>${p[2]}</p><span class="pill">Platzhalter · noch keine Funktionen</span><p class="note">Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.</p></section>`;}
|
}else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`<section class="empty"><div class="symbol">+</div><h2>Platz für den nächsten Schritt.</h2><p>${p[2]}</p><span class="pill">Platzhalter · noch keine Funktionen</span><p class="note">Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.</p></section>`;}
|
||||||
if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));}
|
if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));}
|
||||||
}catch(e){error.textContent=e.message;view.innerHTML=heading('VERBINDUNG','Status nicht verfügbar','Die API ist gerade nicht erreichbar. Angezeigte Messwerte wurden verworfen.');}finally{refreshing=false;}}
|
}catch(e){error.textContent=e.message;view.innerHTML=heading('VERBINDUNG','Status nicht verfügbar','Die API ist gerade nicht erreichbar. Angezeigte Messwerte wurden verworfen.');}finally{refreshing=false;}}
|
||||||
|
|||||||
+4
-4
@@ -10,7 +10,7 @@ window.CatalogUI = (() => {
|
|||||||
function html(library, downloads=false) {
|
function html(library, downloads=false) {
|
||||||
return `<section id="live-catalog"><div class="section-heading"><div><span class="kicker">HUGGING FACE · LIVE-KATALOG</span><h2>${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}</h2><p>${downloads ? 'Abgeschlossene Einträge ausblenden. Die Dateien bleiben in deiner Bibliothek.' : library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}</p></div><button class="secondary" id="hub-refresh">Aktualisieren</button></div>
|
return `<section id="live-catalog"><div class="section-heading"><div><span class="kicker">HUGGING FACE · LIVE-KATALOG</span><h2>${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}</h2><p>${downloads ? 'Abgeschlossene Einträge ausblenden. Die Dateien bleiben in deiner Bibliothek.' : library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}</p></div><button class="secondary" id="hub-refresh">Aktualisieren</button></div>
|
||||||
<div id="hub-capacity" class="small muted"></div><div id="hub-job"></div><p id="hub-message" role="status" aria-live="polite"></p>
|
<div id="hub-capacity" class="small muted"></div><div id="hub-job"></div><p id="hub-message" role="status" aria-live="polite"></p>
|
||||||
${downloads ? '' : library ? '<div id="hub-results" class="model-grid"></div>' : `<form id="hub-search" class="hub-search"><label>Modell oder Anbieter<input name="q" maxlength="120" placeholder="z. B. Qwen oder FLUX" autocomplete="off"></label><button>Suchen</button></form><div class="hub-layout"><div><div class="hub-list-heading"><span id="hub-count" class="small muted">Beliebte Modelle</span><label class="sr-only" for="hub-sort">Ergebnisse sortieren</label><select id="hub-sort"><option value="downloads">Meiste Downloads</option><option value="name">Name A–Z</option></select></div><div id="hub-results" class="hub-results"></div></div><section id="hub-files" class="card hub-detail" aria-label="Modelldetails"><span class="kicker">01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN</span><h2>Welches Modell interessiert dich?</h2><p>Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.</p><p class="note">Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.</p></section></div>`}</section>`;
|
${downloads ? '' : library ? '<div id="hub-results" class="model-grid"></div>' : `<form id="hub-search" class="hub-search"><label>Modell oder Anbieter<input name="q" maxlength="120" placeholder="z. B. Qwen oder FLUX" autocomplete="off"></label><button>Suchen</button></form><div class="hub-layout"><div><div class="hub-list-heading"><span id="hub-count" class="small muted">Beliebte Modelle</span><label class="sr-only" for="hub-sort">Ergebnisse sortieren</label><select id="hub-sort"><option value="downloads">Meiste Downloads</option><option value="name">Name A–Z</option><option value="newest">Neu hinzugefügt</option></select></div><div id="hub-results" class="hub-results"></div></div><section id="hub-files" class="card hub-detail" aria-label="Modelldetails"><span class="kicker">01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN</span><h2>Welches Modell interessiert dich?</h2><p>Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.</p><p class="note">Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.</p></section></div>`}</section>`;
|
||||||
}
|
}
|
||||||
function bind(kind, library, downloads=false) {
|
function bind(kind, library, downloads=false) {
|
||||||
const root = document.querySelector('#live-catalog'); if (!root) return;
|
const root = document.querySelector('#live-catalog'); if (!root) return;
|
||||||
@@ -20,7 +20,7 @@ window.CatalogUI = (() => {
|
|||||||
const fitText = a => a ? `${a.label}${a.weights_fit_now===true?' · Aktuell: Gewichte passen':a.weights_fit_now===false?' · Aktuell: keine GPU mit genug freiem Speicher für diese Datei':''}` : 'Größe und Speicher werden ermittelt …';
|
const fitText = a => a ? `${a.label}${a.weights_fit_now===true?' · Aktuell: Gewichte passen':a.weights_fit_now===false?' · Aktuell: keine GPU mit genug freiem Speicher für diese Datei':''}` : 'Größe und Speicher werden ermittelt …';
|
||||||
const installed = file => detail && state.entries.some(x => x.repo === detail.repo && x.revision === detail.revision && x.file === file.name);
|
const installed = file => detail && state.entries.some(x => x.repo === detail.repo && x.revision === detail.revision && x.file === file.name);
|
||||||
function renderModels() {
|
function renderModels() {
|
||||||
const sorted = [...models].sort(el('hub-sort').value === 'name' ? (a,b) => a.repo.localeCompare(b.repo) : (a,b) => (b.downloads||0)-(a.downloads||0));
|
const sorted = [...models].sort(el('hub-sort').value === 'name' ? (a,b) => a.repo.localeCompare(b.repo) : el('hub-sort').value === 'newest' ? (a,b) => (Date.parse(b.created_at)||0)-(Date.parse(a.created_at)||0) : (a,b) => (b.downloads||0)-(a.downloads||0));
|
||||||
el('hub-count').textContent = `${models.length} Ergebnisse · höchstens 20 pro Suche`;
|
el('hub-count').textContent = `${models.length} Ergebnisse · höchstens 20 pro Suche`;
|
||||||
el('hub-results').innerHTML = sorted.map(m => `<button type="button" class="hub-model ${selected === m.repo ? 'selected' : ''}" data-repo="${escape(m.repo)}" aria-pressed="${selected === m.repo}"><span class="hub-author">${escape(m.repo.split('/')[0])}</span><strong>${escape(m.repo.split('/').slice(1).join('/'))}</strong><span class="hub-model-meta">↓ ${number(m.downloads)} Downloads · ${m.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="hub-model-meta" data-capacity="${escape(m.repo)}">${escape(m.capacity ? `${size(m.capacity.min_bytes)} – ${size(m.capacity.max_bytes)} Hauptgewichte · kleinste Variante: ${fitText(m.capacity)}` : m.capacityError || 'Größe und Speicher werden ermittelt …')}</span>${state.entries.some(x => x.repo === m.repo) ? '<span class="tag">Dateien in Bibliothek</span>' : ''}</button>`).join('') || '<div class="empty"><h3>Keine Treffer</h3><p>Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.</p></div>';
|
el('hub-results').innerHTML = sorted.map(m => `<button type="button" class="hub-model ${selected === m.repo ? 'selected' : ''}" data-repo="${escape(m.repo)}" aria-pressed="${selected === m.repo}"><span class="hub-author">${escape(m.repo.split('/')[0])}</span><strong>${escape(m.repo.split('/').slice(1).join('/'))}</strong><span class="hub-model-meta">↓ ${number(m.downloads)} Downloads · ${m.gated ? 'Zugang beschränkt' : 'Öffentlich'}</span><span class="hub-model-meta" data-capacity="${escape(m.repo)}">${escape(m.capacity ? `${size(m.capacity.min_bytes)} – ${size(m.capacity.max_bytes)} Hauptgewichte · kleinste Variante: ${fitText(m.capacity)}` : m.capacityError || 'Größe und Speicher werden ermittelt …')}</span>${state.entries.some(x => x.repo === m.repo) ? '<span class="tag">Dateien in Bibliothek</span>' : ''}</button>`).join('') || '<div class="empty"><h3>Keine Treffer</h3><p>Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.</p></div>';
|
||||||
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
|
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
|
||||||
@@ -45,7 +45,7 @@ window.CatalogUI = (() => {
|
|||||||
}
|
}
|
||||||
async function search(q) {
|
async function search(q) {
|
||||||
const sequence = ++searchSequence; message('Modelle werden gesucht …');
|
const sequence = ++searchSequence; message('Modelle werden gesucht …');
|
||||||
try { const result = await api('/search?'+new URLSearchParams({q,kind})); if (!root.isConnected || sequence !== searchSequence) return; models = result.models; renderModels(); enrich(sequence); message(kind === 'audio' ? 'Audio-Suche: derzeit Text-to-Speech-Modelle.' : 'Ergebnisse nach Beliebtheit. Wähle ein Modell für die Dateiauswahl.'); }
|
try { const result = await api('/search?'+new URLSearchParams({q,kind,sort:el('hub-sort').value})); if (!root.isConnected || sequence !== searchSequence) return; models = result.models; renderModels(); enrich(sequence); message(kind === 'audio' ? 'Audio-Suche: derzeit Text-to-Speech-Modelle.' : el('hub-sort').value==='newest'?'Neueste Hub-Repositories zuerst (Erstellungsdatum, nicht letztes Update).':'Wähle ein Modell für die Dateiauswahl. Name A–Z sortiert die angezeigten Treffer.'); }
|
||||||
catch(error) { if (sequence === searchSequence) message(error.message); }
|
catch(error) { if (sequence === searchSequence) message(error.message); }
|
||||||
}
|
}
|
||||||
async function enrich(sequence){
|
async function enrich(sequence){
|
||||||
@@ -90,7 +90,7 @@ window.CatalogUI = (() => {
|
|||||||
} catch(error) { if (sequence === detailSequence) { el('hub-files').innerHTML = '<p>Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.</p>'; message(error.message); } }
|
} catch(error) { if (sequence === detailSequence) { el('hub-files').innerHTML = '<p>Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.</p>'; message(error.message); } }
|
||||||
}
|
}
|
||||||
el('hub-refresh').onclick = refresh;
|
el('hub-refresh').onclick = refresh;
|
||||||
if (!library && !downloads) { el('hub-sort').onchange = renderModels; el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); }
|
if (!library && !downloads) { el('hub-sort').onchange = () => search(new FormData(el('hub-search')).get('q').trim()); el('hub-search').onsubmit = event => { event.preventDefault(); search(new FormData(event.target).get('q').trim()); }; search(''); }
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
return {html,bind};
|
return {html,bind};
|
||||||
|
|||||||
+4
-3
@@ -86,10 +86,11 @@ class Catalog:
|
|||||||
path=self.root/ident/('model'+PurePosixPath(entry['file']).suffix)
|
path=self.root/ident/('model'+PurePosixPath(entry['file']).suffix)
|
||||||
if not path.is_file() or path.stat().st_size!=entry['size']:raise ValueError('Modelldatei fehlt oder ist unvollständig.')
|
if not path.is_file() or path.stat().st_size!=entry['size']:raise ValueError('Modelldatei fehlt oder ist unvollständig.')
|
||||||
return entry
|
return entry
|
||||||
def search(self,q,kind):
|
def search(self,q,kind,sort="downloads"):
|
||||||
if kind not in KINDS or not isinstance(q,str) or len(q)>120:raise ValueError('Ungültige Suche.')
|
if kind not in KINDS or not isinstance(q,str) or len(q)>120:raise ValueError('Ungültige Suche.')
|
||||||
rows=metadata('/api/models?'+urllib.parse.urlencode(dict(search=q,filter=KINDS[kind],limit=20,sort='downloads',direction=-1)))
|
if sort not in ('downloads','name','newest'):raise ValueError('Ungültige Sortierung.')
|
||||||
return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False)) for x in rows]}
|
rows=metadata('/api/models?'+urllib.parse.urlencode(dict(search=q,filter=KINDS[kind],limit=20,sort='createdAt' if sort=='newest' else 'downloads',direction=-1)))
|
||||||
|
return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False),created_at=x.get('createdAt')) for x in rows]}
|
||||||
def files(self,repo):
|
def files(self,repo):
|
||||||
repo=repo_id(repo)
|
repo=repo_id(repo)
|
||||||
with self.cache_lock:
|
with self.cache_lock:
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
|
|||||||
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
|
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||||
COPY image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/
|
COPY image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/
|
||||||
COPY 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 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/
|
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||||
|
|||||||
@@ -65,5 +65,9 @@ Quelle unter `/opt/deck-comfy` und die gepinnte ComfyUI-GGUF-Erweiterung. Konkre
|
|||||||
Commits stehen im Dockerfile; Paketversionen in `image-requirements.lock`.
|
Commits stehen im Dockerfile; Paketversionen in `image-requirements.lock`.
|
||||||
PyTorch 2.11.0 CUDA 12.8 benötigt kompatiblen bestehenden Treiber. Keine
|
PyTorch 2.11.0 CUDA 12.8 benötigt kompatiblen bestehenden Treiber. Keine
|
||||||
Treiberänderung oder automatischer Neustart. Der Deck-Service benötigt ausreichend
|
Treiberänderung oder automatischer Neustart. Der Deck-Service benötigt ausreichend
|
||||||
RAM für CPU-Textencoder; die Testinstallation begrenzt ihn auf 32 GiB und zwei
|
RAM für Modellladen und Offload; die Testinstallation begrenzt ihn auf 32 GiB und zwei
|
||||||
CPU-Kerne. Modelle/Encoder/VAE werden weiterhin separat über die Bibliothek geladen.
|
CPU-Kerne. Modelle/Encoder/VAE werden weiterhin separat über die Bibliothek geladen.
|
||||||
|
|
||||||
|
Decks `image_encoder_node.py` muss mitinstalliert werden. Der Bildtest macht
|
||||||
|
5080 und 3060 per UUID sichtbar und lädt den Textencoder explizit auf die zweite
|
||||||
|
GPU. Beide Geräte werden vor dem Start auf Fremdbelegung geprüft.
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||||
FILES = ['image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.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 = ['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','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.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):
|
def run(*args, check=True, interactive=False):
|
||||||
@@ -186,7 +186,7 @@ def update(base, rollback=False):
|
|||||||
backup_name=config['name']+'-previous'
|
backup_name=config['name']+'-previous'
|
||||||
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
||||||
stamp=str(time.time_ns())
|
stamp=str(time.time_ns())
|
||||||
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification','image-runtime'))
|
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime'))
|
||||||
was_running=previous['State']['Running']
|
was_running=previous['State']['Running']
|
||||||
if was_running:run('docker','stop','--time','15',config['name'])
|
if was_running:run('docker','stop','--time','15',config['name'])
|
||||||
run('docker','rename',config['name'],backup_name)
|
run('docker','rename',config['name'],backup_name)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Athena-Anwendungen: native Laufzeiten und Docker
|
||||||
|
|
||||||
|
Geprüft am 28.09.2026: ausschließlich Container-Metadaten, Dockerfiles,
|
||||||
|
Compose-Buildpfade und gezielte Adapter-Quellstellen auf Athena. Keine Nutzerlogs,
|
||||||
|
Trainingsdaten, Stimmen, Prompts oder Medien gelesen. Keine Anwendung migriert.
|
||||||
|
Die folgende Einschätzung ist eine Quellcode-/Paketprüfung, kein nativer
|
||||||
|
Ende-zu-Ende-Test auf einem frischen Debian.
|
||||||
|
|
||||||
|
| Anwendung auf Athena | Native Ausführung | Besondere Anforderungen / Entscheidung |
|
||||||
|
|---|---|---|
|
||||||
|
| X-VC (`mike-ai-xvc-studio`) | Ja, Python-Umgebung samt eigener Gradio-GUI | Python, CUDA-PyTorch, FFmpeg, libsndfile. Bestehende Resemble-Enhance-Patches und abgestimmte NumPy/SciPy-Versionen übernehmen; eigene Umgebung. |
|
||||||
|
| OmniVoice / Voice Studio (`mike-ai-voice-studio`) | Ja, Python-Paket und `omnivoice-demo` | CUDA-PyTorch, torchaudio, omnivoice-triton, FFmpeg/libsndfile. Docker dient derzeit der Paketierung. |
|
||||||
|
| Applio/RVC, einschließlich Training (`mike-ai-applio-studio`) | Ja, eigene Python-Umgebung und `app.py` | Angepasste CUDA-12.8-PyTorch-Pakete, FFmpeg/PortAudio; persistente Stimmen- und Trainingsverzeichnisse. |
|
||||||
|
| Mikes-Applio-UI | Webserver ja; aktueller Worker-Adapter Docker-abhängig | FastAPI/Uvicorn plus statisches Frontend laufen nativ. `adapters/real_runner.py`, Realtime und Diagnostik verwenden derzeit `docker exec`. Für einen nativen Applio-Worker muss dieser Adapter ersetzt werden. |
|
||||||
|
| ACE-Step 1.5 | Ja, Python/uv, Gradio oder API | Der aktuelle Worker basiert auf dem Upstream-Image plus lokalem API-Routenpatch. Bei nativer Installation denselben Patch und die Version übernehmen. |
|
||||||
|
| ACE-Step Community UI | Ja, Node.js plus gebautes Frontend | Aktuell Node 22, React/Express und nginx. Native UI kann über HTTP auch einen Docker-Worker nutzen und umgekehrt. Lokale Kompatibilitätspatches beachten. |
|
||||||
|
| YuE2 + Community WebUI | Ja, Python-Paket/CLI und WebUI | YuE- und WebUI-Versionen sind getrennt gepinnt. SheetSage2 benötigt wegen Transformers/NumPy-Konflikten eine eigene Umgebung; keine gemeinsame globale Python-Installation. |
|
||||||
|
| Audio trennen (`mike-ai-stem-separator`) | Ja, Python/FastAPI | audio-separator, ONNX Runtime GPU, CUDA-PyTorch und FFmpeg; ClearVoice separat wegen NumPy-Konflikten. Kein eigenständiger Container namens „Audio Trainer“ gefunden; RVC-Training liegt bei Applio. |
|
||||||
|
| LTX-2 / LTX Desktop | Technisch auch nativ, aber deutlich aufwendiger als ein Webserver | Der konkrete Aufbau ist eine extrahierte AppImage plus Xvfb, Openbox, x11vnc, noVNC und Python-Backend. Für genau diese vollständige Desktop-GUI ist Docker als gekapselte Ausnahme sinnvoll. Ein nativer LTX-Worker mit Deck-GUI wäre eine andere Integration. |
|
||||||
|
|
||||||
|
## Konsequenz für Deck
|
||||||
|
|
||||||
|
Keine der geprüften Modelllaufzeiten ist grundsätzlich nur in Docker ausführbar.
|
||||||
|
Docker ist meistens das vorhandene Auslieferungsformat. Eine eigene GUI ist kein
|
||||||
|
Grund für eine Docker-Pflicht. Der konkrete Mikes-Applio-Adapter benötigt Docker,
|
||||||
|
bis er umgebaut wird; LTX Desktop ist eine sinnvolle optionale Container-Anwendung.
|
||||||
|
|
||||||
|
Native Komponenten erhalten jeweils eigene versionierte Python-/Node-Umgebungen,
|
||||||
|
Prozessverwaltung und persistente Datenpfade. Unterschiedliche Python-Versionen
|
||||||
|
(z. B. 3.12) müssen auch auf Debian 13 reproduzierbar bereitgestellt werden;
|
||||||
|
System-Python nicht überschreiben. UI, Worker und Betriebsart sind getrennte
|
||||||
|
Eigenschaften. Deck verwaltet GPU-Zugriffe gemeinsam über beide Betriebsarten.
|
||||||
|
Ein begrenzter Docker-Hilfsdienst darf nur registrierte Deck-Anwendungen steuern.
|
||||||
|
Der Webserver bekommt keinen freien Docker-Socket-/Rootzugriff.
|
||||||
|
|
||||||
|
Bestehende Anwendungen bleiben extern verwaltet, bis eine Übernahme ausdrücklich
|
||||||
|
beauftragt und je Anwendung getestet wurde. Native Rezepte sind noch nicht implementiert.
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
Die auf Athena vorhandenen Dockerfiles unter `/opt/mike-ai/xvc-studio`,
|
||||||
|
`omnivoice-studio`, `yue2-3b`, `stem-separator`, `acestep-test/worker`,
|
||||||
|
`acestep-test/ace-step-ui`, `Mikes-Applio-UI`, sowie
|
||||||
|
`stack/experiments/applio-rvc` und `stack/platform/ltx2-studio`.
|
||||||
|
|
||||||
|
Zusätzliche Upstream-Prüfung:
|
||||||
|
- [X-VC: native Conda-/Pip-Installation](https://github.com/Jerrister/X-VC/blob/main/README.md)
|
||||||
|
- [ACE-Step: Python/uv, Gradio und API](https://github.com/ace-step/ACE-Step-1.5/blob/main/docs/en/INSTALL.md)
|
||||||
|
- [Applio](https://github.com/IAHispano/Applio)
|
||||||
|
- [YuE2](https://github.com/multimodal-art-projection/YuE)
|
||||||
|
- [LTX Desktop](https://github.com/Lightricks/LTX-Desktop)
|
||||||
|
|
||||||
|
## In diesem Auftrag ausgeführte Änderungen
|
||||||
|
|
||||||
|
Der eindeutig der RTX 3060 zugeordnete Container `mike-ai-qwen3-tts` wurde
|
||||||
|
auf ausdrücklichen Nutzerauftrag regulär gestoppt. Die bisherige TTS-Ausgabe
|
||||||
|
ist damit vorerst nicht verfügbar. Keine Löschung, keine Änderung von
|
||||||
|
Restart-Policy, Treibern, WireGuard oder Hostkonfiguration.
|
||||||
|
|
||||||
|
Decks eigener Zwei-GPU-Bildtest: Qwen-Image-2.1 Q8_0, zugeordneter
|
||||||
|
INT8-Textencoder, 1024 × 1024, 25 Schritte, Seed 42. Zwei erfolgreiche
|
||||||
|
Durchläufe mit jeweils etwa 51 Sekunden. Synchron gemessene Spitzenwerte:
|
||||||
|
RTX 3060: 8065 MiB / 100 %, RTX 5080: 8842 MiB / 100 %. Nach dem Auftrag
|
||||||
|
wird der eigene Worker beendet. Testdaten liegen getrennt von Nutzeraufträgen.
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
window.ImageTestUI=(()=>{
|
window.ImageTestUI=(()=>{
|
||||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
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;}
|
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 html(readonly=false){return `<section id="image-test"><div class="section-heading"><div><h2>${readonly?'Bildlaufzeit':'Bildmodell testen'}</h2><p>Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.</p></div></div>${readonly?'':`<form id="image-test-form" class="card"><label>Bildprofil<select name="profile_id" id="image-test-profile" required><option value="">Profile werden geladen …</option></select></label><p id="image-profile-info"></p><label>Prompt<textarea name="prompt" maxlength="4000" rows="5" required placeholder="Beschreibe das gewünschte Bild …"></textarea></label><button id="image-generate" disabled>Bild generieren</button><p class="small">Auflösung, Schritte, Guidance und Seed stammen aus dem Profil. Maximal 1024 × 1024 Pixel. Nur eine freie GPU wird genutzt; Deck stoppt keine produktiven Modelle. Der erste Start kann einige Minuten dauern.</p></form>`}<p class="note">Textencoder: CPU / System-RAM · Diffusionsmodell und VAE: GPU / VRAM. GPU-Last entsteht nicht während aller Verarbeitungsschritte. <a class="link" href="#hardware">Aktuelle Hardware-Messwerte →</a></p><p id="image-test-message" role="status"></p><section id="image-test-state" class="card"></section><section id="image-test-result"></section></section>`;}
|
function html(readonly=false){return `<section id="image-test"><div class="section-heading"><div><h2>${readonly?'Bildlaufzeit':'Bildmodell testen'}</h2><p>Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.</p></div></div>${readonly?'':`<form id="image-test-form" class="card"><label>Bildprofil<select name="profile_id" id="image-test-profile" required><option value="">Profile werden geladen …</option></select></label><p id="image-profile-info"></p><label>Prompt<textarea name="prompt" maxlength="4000" rows="5" required placeholder="Beschreibe das gewünschte Bild …"></textarea></label><button id="image-generate" disabled>Bild generieren</button><p class="small">Auflösung, Schritte, Guidance und Seed stammen aus dem Profil. Maximal 1024 × 1024 Pixel. Beide GPUs müssen frei sein; Deck stoppt keine produktiven Modelle. Der erste Start kann einige Minuten dauern.</p></form>`}<p class="note">Textencoder: RTX 3060 · Diffusionsmodell und VAE: RTX 5080. CPU/RAM werden weiterhin zum Laden und Auslagern benötigt. GPU-Last entsteht nicht während aller Verarbeitungsschritte. <a class="link" href="#hardware">Aktuelle Hardware-Messwerte →</a></p><p id="image-test-message" role="status"></p><section id="image-test-state" class="card"></section><section id="image-test-result"></section></section>`;}
|
||||||
function bind(readonly=false){
|
function bind(readonly=false){
|
||||||
const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=false;
|
const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=false;
|
||||||
const message=t=>{if(root.isConnected)el('image-test-message').textContent=t;};
|
const message=t=>{if(root.isConnected)el('image-test-message').textContent=t;};
|
||||||
function selection(){if(readonly)return;const p=profiles.find(p=>p.id===el('image-test-profile').value);el('image-profile-info').textContent=p?`${p.parameters.width} × ${p.parameters.height} · ${p.parameters.steps} Schritte · Guidance ${p.parameters.guidance} · Seed ${p.parameters.seed}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'';el('image-generate').disabled=submitting||!p?.runnable||!status?.runtime_installed||status?.job?.state==='running';}
|
function selection(){if(readonly)return;const p=profiles.find(p=>p.id===el('image-test-profile').value);el('image-profile-info').textContent=p?`${p.parameters.width} × ${p.parameters.height} · ${p.parameters.steps} Schritte · Guidance ${p.parameters.guidance} · Seed ${p.parameters.seed}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'';el('image-generate').disabled=submitting||!p?.runnable||!status?.runtime_installed||status?.job?.state==='running';}
|
||||||
async function refresh(){try{status=await api('image-tests');if(!root.isConnected)return;const j=status.job,signature=JSON.stringify(j);if(signature!==lastJob){lastJob=signature;el('image-test-state').innerHTML=j?`<span class="pill">${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}</span><h3>${e(j.profile_name)}</h3><p>${e(j.phase)}</p><p class="small">${e(j.gpu)} · Seed ${e(j.seed)}</p>${j.state==='running'?'<button class="secondary" id="image-cancel">Bildtest abbrechen</button>':''}`:`<h3>${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}</h3><p>Noch kein Bildtest gestartet.</p>`;
|
async function refresh(){try{status=await api('image-tests');if(!root.isConnected)return;const j=status.job,signature=JSON.stringify(j);if(signature!==lastJob){lastJob=signature;el('image-test-state').innerHTML=j?`<span class="pill">${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}</span><h3>${e(j.profile_name)}</h3><p>${e(j.phase)}</p><p class="small">${e(j.gpu)}${j.encoder_gpu?' · Textencoder: '+e(j.encoder_gpu):''} · Seed ${e(j.seed)}</p>${j.state==='running'?'<button class="secondary" id="image-cancel">Bildtest abbrechen</button>':''}`:`<h3>${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}</h3><p>Noch kein Bildtest gestartet.</p>`;
|
||||||
el('image-test-result').innerHTML=j?.state==='complete'?`<figure class="card"><img class="generated-image" src="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" alt="Ergebnis des Bildtests"><figcaption><a class="link" href="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" download="athena-${e(j.id)}.png">PNG herunterladen</a></figcaption></figure>`:'';
|
el('image-test-result').innerHTML=j?.state==='complete'?`<figure class="card"><img class="generated-image" src="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" alt="Ergebnis des Bildtests"><figcaption><a class="link" href="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" download="athena-${e(j.id)}.png">PNG herunterladen</a></figcaption></figure>`:'';
|
||||||
el('image-cancel')?.addEventListener('click',async event=>{event.target.disabled=true;try{await api('image-tests/cancel',{});message('Abbruch angefordert; der eigene Bildworker wird beendet.');}catch(error){message(error.message);}});
|
el('image-cancel')?.addEventListener('click',async event=>{event.target.disabled=true;try{await api('image-tests/cancel',{});message('Abbruch angefordert; der eigene Bildworker wird beendet.');}catch(error){message(error.message);}});
|
||||||
}selection();}catch(error){message(error.message);}finally{if(root.isConnected)timer=setTimeout(refresh,2000);}}
|
}selection();}catch(error){message(error.message);}finally{if(root.isConnected)timer=setTimeout(refresh,2000);}}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Deck-owned Comfy node: Qwen text encoder explicitly on the second visible GPU."""
|
||||||
|
import torch
|
||||||
|
import comfy.sd
|
||||||
|
import folder_paths
|
||||||
|
|
||||||
|
class DeckTextEncoderLoader:
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(cls):
|
||||||
|
return {'required':{'clip_name':(folder_paths.get_filename_list('text_encoders'),)}}
|
||||||
|
RETURN_TYPES=('CLIP',)
|
||||||
|
FUNCTION='load_clip'
|
||||||
|
CATEGORY='Deck'
|
||||||
|
def load_clip(self,clip_name):
|
||||||
|
if torch.cuda.device_count()!=2:raise RuntimeError('Deck requires the two reserved GPUs.')
|
||||||
|
device=torch.device('cuda:1')
|
||||||
|
path=folder_paths.get_full_path_or_raise('text_encoders',clip_name)
|
||||||
|
clip=comfy.sd.load_clip(ckpt_paths=[path],embedding_directory=folder_paths.get_folder_paths('embeddings'),clip_type=comfy.sd.CLIPType.QWEN_IMAGE,
|
||||||
|
model_options={'load_device':device,'offload_device':torch.device('cpu'),'initial_device':torch.device('cpu')})
|
||||||
|
if clip.patcher.load_device!=device:raise RuntimeError('Textencoder could not be assigned to the reserved GPU.')
|
||||||
|
return (clip,)
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS={'DeckTextEncoderLoader':DeckTextEncoderLoader}
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS={'DeckTextEncoderLoader':'Deck · Textencoder auf zweiter GPU'}
|
||||||
+23
-14
@@ -25,10 +25,18 @@ def probe():
|
|||||||
gpu,_=line.split(',',1);counts[gpu.strip()]=counts.get(gpu.strip(),0)+1
|
gpu,_=line.split(',',1);counts[gpu.strip()]=counts.get(gpu.strip(),0)+1
|
||||||
return [dict(uuid=u.strip(),name=n.strip(),total_mib=float(t),free_mib=float(f),processes=counts.get(u.strip(),0)) for u,n,t,f in (line.split(',') for line in rows.splitlines())]
|
return [dict(uuid=u.strip(),name=n.strip(),total_mib=float(t),free_mib=float(f),processes=counts.get(u.strip(),0)) for u,n,t,f in (line.split(',') for line in rows.splitlines())]
|
||||||
|
|
||||||
|
def select_gpus(devices,model_size,encoder_size,vae_size):
|
||||||
|
needed=(model_size+vae_size+3*GIB)/1024**2
|
||||||
|
images=[g for g in devices if 'RTX 5080' in g['name'] and g['processes']==0 and g['free_mib']>needed]
|
||||||
|
encoders=[g for g in devices if 'RTX 3060' in g['name'] and g['processes']==0 and g['free_mib']>(encoder_size+2*GIB)/1024**2]
|
||||||
|
if not encoders:raise ValueError('Textencoder benötigt eine freie RTX 3060 mit ausreichend VRAM. Kein automatischer CPU-Fallback.')
|
||||||
|
if not images:raise ValueError('Bildmodell benötigt eine freie RTX 5080 mit ausreichend VRAM. Deck stoppt keine anderen Modelle.')
|
||||||
|
return max(images,key=lambda g:g['free_mib']),encoders[0]
|
||||||
|
|
||||||
def workflow(prompt,params,seed):
|
def workflow(prompt,params,seed):
|
||||||
return {
|
return {
|
||||||
'1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}},
|
'1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}},
|
||||||
'2':{'class_type':'CLIPLoader','inputs':{'clip_name':'encoder.safetensors','type':'qwen_image','device':'cpu'}},
|
'2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors'}},
|
||||||
'3':{'class_type':'VAELoader','inputs':{'vae_name':'vae.safetensors'}},
|
'3':{'class_type':'VAELoader','inputs':{'vae_name':'vae.safetensors'}},
|
||||||
'4':{'class_type':'TextEncodeQwenImage21','inputs':{'clip':['2',0],'prompt':prompt,'negative_prompt':'','resolution':max(params['width'],params['height'])}},
|
'4':{'class_type':'TextEncodeQwenImage21','inputs':{'clip':['2',0],'prompt':prompt,'negative_prompt':'','resolution':max(params['width'],params['height'])}},
|
||||||
'5':{'class_type':'EmptyLatentImage','inputs':{'width':params['width'],'height':params['height'],'batch_size':1}},
|
'5':{'class_type':'EmptyLatentImage','inputs':{'width':params['width'],'height':params['height'],'batch_size':1}},
|
||||||
@@ -87,26 +95,25 @@ class ImageTests:
|
|||||||
if raw!='max' and int(raw)-current<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
|
if raw!='max' and int(raw)-current<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
|
||||||
except FileNotFoundError:pass
|
except FileNotFoundError:pass
|
||||||
if mem.get('MemAvailable',0)<required_ram+4*GIB:raise ValueError('Aktuell zu wenig freier System-RAM; produktive Dienste bleiben unverändert.')
|
if mem.get('MemAvailable',0)<required_ram+4*GIB:raise ValueError('Aktuell zu wenig freier System-RAM; produktive Dienste bleiben unverändert.')
|
||||||
needed=(model['size']+vae['size']+3*GIB)/1024**2
|
gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size']);self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||||
gpus=[g for g in probe() if g['processes']==0 and g['free_mib']>needed]
|
|
||||||
if not gpus:raise ValueError('Keine unbelegte GPU mit ausreichend freiem VRAM. Deck stoppt keine produktiven Modelle.')
|
|
||||||
gpu=max(gpus,key=lambda g:g['free_mib']);self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
|
||||||
if shutil.disk_usage(self.root).free<10*GIB:raise ValueError('Weniger als 10 GiB freier Plattenspeicher.')
|
if shutil.disk_usage(self.root).free<10*GIB:raise ValueError('Weniger als 10 GiB freier Plattenspeicher.')
|
||||||
job_id=uuid.uuid4().hex;seed=params['seed'] if params['seed']>=0 else secrets.randbelow(2147483648)
|
job_id=uuid.uuid4().hex;seed=params['seed'] if params['seed']>=0 else secrets.randbelow(2147483648)
|
||||||
self.cancel.clear();self.job=dict(id=job_id,state='running',phase='Bildlaufzeit startet',profile_id=profile_id,profile_name=profile['name'],started_at=time.time(),gpu=gpu['name'],seed=seed,parameters=params)
|
self.cancel.clear();self.job=dict(id=job_id,state='running',phase='Bildlaufzeit startet',profile_id=profile_id,profile_name=profile['name'],started_at=time.time(),gpu=gpu['name'],encoder_gpu=encoder_gpu['name'],seed=seed,parameters=params)
|
||||||
self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,model,encoder,vae),daemon=True).start()
|
self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae),daemon=True).start()
|
||||||
return dict(self.job)
|
return dict(self.job)
|
||||||
def _run(self,job_id,prompt,params,seed,gpu,model,encoder,vae):
|
def _run(self,job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae):
|
||||||
directory=self.root/job_id;process=None
|
directory=self.root/job_id;process=None
|
||||||
try:
|
try:
|
||||||
directory.mkdir(mode=0o700)
|
directory.mkdir(mode=0o700)
|
||||||
for role,item,filename in [('unet',model,'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]:
|
for role,item,filename in [('unet',model,'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]:
|
||||||
dest=directory/'models'/role;dest.mkdir(parents=True);(dest/filename).symlink_to(self.model_path(item))
|
dest=directory/'models'/role;dest.mkdir(parents=True);(dest/filename).symlink_to(self.model_path(item))
|
||||||
for folder in ('output','temp','user','input'):(directory/folder).mkdir()
|
for folder in ('output','temp','user','input'):(directory/folder).mkdir()
|
||||||
config={'deck':{'base_path':str(directory/'models'),'unet':'unet','clip':'clip','vae':'vae'}}
|
custom=directory/'custom_nodes'/'deck_encoder';custom.mkdir(parents=True)
|
||||||
|
shutil.copyfile(Path(__file__).parent/'image_encoder_node.py',custom/'__init__.py')
|
||||||
|
config={'deck_nodes':{'base_path':str(directory),'custom_nodes':'custom_nodes'},'deck':{'base_path':str(directory/'models'),'unet':'unet','clip':'clip','vae':'vae'}}
|
||||||
(directory/'paths.json').write_text(json.dumps(config)) # JSON is valid YAML.
|
(directory/'paths.json').write_text(json.dumps(config)) # JSON is valid YAML.
|
||||||
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
|
||||||
env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid'],OMP_NUM_THREADS='2',MKL_NUM_THREADS='2',HOME=str(directory),HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',PYTHONDONTWRITEBYTECODE='1')
|
env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid']+','+encoder_gpu['uuid'],OMP_NUM_THREADS='2',MKL_NUM_THREADS='2',HOME=str(directory),HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',PYTHONDONTWRITEBYTECODE='1')
|
||||||
args=[str(self.runtime.paths()[0] if self.runtime else PYTHON),str((self.runtime.paths()[1] if self.runtime else COMFY)/'main.py'),'--listen','127.0.0.1','--port',str(port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(directory/'paths.json'),'--output-directory',str(directory/'output'),'--temp-directory',str(directory/'temp'),'--user-directory',str(directory/'user'),'--input-directory',str(directory/'input')]
|
args=[str(self.runtime.paths()[0] if self.runtime else PYTHON),str((self.runtime.paths()[1] if self.runtime else COMFY)/'main.py'),'--listen','127.0.0.1','--port',str(port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(directory/'paths.json'),'--output-directory',str(directory/'output'),'--temp-directory',str(directory/'temp'),'--user-directory',str(directory/'user'),'--input-directory',str(directory/'input')]
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.cancel.is_set():raise InterruptedError()
|
if self.cancel.is_set():raise InterruptedError()
|
||||||
@@ -120,8 +127,10 @@ class ImageTests:
|
|||||||
def guard():
|
def guard():
|
||||||
if self.cancel.is_set():raise InterruptedError()
|
if self.cancel.is_set():raise InterruptedError()
|
||||||
if process.poll() is not None:raise ValueError('Bildlaufzeit wurde beendet (Speicherlimit oder Startfehler).')
|
if process.poll() is not None:raise ValueError('Bildlaufzeit wurde beendet (Speicherlimit oder Startfehler).')
|
||||||
current=next((g for g in probe() if g['uuid']==gpu['uuid']),None)
|
current={g['uuid']:g for g in probe()}
|
||||||
if not current or current['processes']>1:raise ValueError('GPU wird inzwischen von einem weiteren Prozess verwendet. Deck-Test beendet.')
|
for reserved in (gpu,encoder_gpu):
|
||||||
|
row=current.get(reserved['uuid'])
|
||||||
|
if not row or row['processes']>1:raise ValueError('Reservierte GPU wird inzwischen von einem weiteren Prozess verwendet. Deck-Test beendet.')
|
||||||
deadline=time.monotonic()+180
|
deadline=time.monotonic()+180
|
||||||
while True:
|
while True:
|
||||||
guard()
|
guard()
|
||||||
@@ -130,9 +139,9 @@ class ImageTests:
|
|||||||
if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.')
|
if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.')
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
nodes=request('/object_info')
|
nodes=request('/object_info')
|
||||||
required={'UnetLoaderGGUF','CLIPLoader','VAELoader','TextEncodeQwenImage21','EmptyLatentImage','KSampler','VAEDecode','SaveImage'}
|
required={'UnetLoaderGGUF','DeckTextEncoderLoader','VAELoader','TextEncodeQwenImage21','EmptyLatentImage','KSampler','VAEDecode','SaveImage'}
|
||||||
if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Qwen/GGUF-Nodes.')
|
if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Qwen/GGUF-Nodes.')
|
||||||
self._phase('Auftrag wird verarbeitet · Textencoder auf CPU, Bildberechnung auf GPU')
|
self._phase('Auftrag wird verarbeitet · Textencoder auf RTX 3060 · Bildberechnung auf RTX 5080')
|
||||||
response=request('/prompt',{'prompt':workflow(prompt,params,seed),'client_id':'deck-'+job_id});prompt_id=response['prompt_id']
|
response=request('/prompt',{'prompt':workflow(prompt,params,seed),'client_id':'deck-'+job_id});prompt_id=response['prompt_id']
|
||||||
deadline=time.monotonic()+1800
|
deadline=time.monotonic()+1800
|
||||||
while time.monotonic()<deadline:
|
while time.monotonic()<deadline:
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if self.path.startswith('/api/v1/catalog'):
|
if self.path.startswith('/api/v1/catalog'):
|
||||||
try:
|
try:
|
||||||
path=urlsplit(self.path);q=parse_qs(path.query)
|
path=urlsplit(self.path);q=parse_qs(path.query)
|
||||||
if path.path=='/api/v1/catalog/search':return self.respond(self.server.catalog.search(q.get('q',[''])[0],q.get('kind',['chat'])[0]))
|
if path.path=='/api/v1/catalog/search':return self.respond(self.server.catalog.search(q.get('q',[''])[0],q.get('kind',['chat'])[0],q.get('sort',['downloads'])[0]))
|
||||||
if path.path=='/api/v1/catalog/assessment':
|
if path.path=='/api/v1/catalog/assessment':
|
||||||
data=self.server.catalog.files(q.get('repo',[''])[0])
|
data=self.server.catalog.files(q.get('repo',[''])[0])
|
||||||
return self.respond(overview(data['files'],self.server.hardware.snapshot()))
|
return self.respond(overview(data['files'],self.server.hardware.snapshot()))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const Studio=(()=>{
|
|||||||
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
|
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
|
||||||
}
|
}
|
||||||
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section></div>`;}
|
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section></div>`;}
|
||||||
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.</p><p>Textencoder läuft auf CPU, Diffusionsmodell und VAE auf einer freien GPU. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
|
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.</p><p>Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
|
||||||
function openProfile(page,modelId){render(page,true,modelId);}
|
function openProfile(page,modelId){render(page,true,modelId);}
|
||||||
return {render,openProfile};
|
return {render,openProfile};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ class CatalogTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):safe_url(value)
|
with self.assertRaises(ValueError):safe_url(value)
|
||||||
for value in ['../foo','a/b/c','https://x','a/..']:
|
for value in ['../foo','a/b/c','https://x','a/..']:
|
||||||
with self.assertRaises(ValueError):repo_id(value)
|
with self.assertRaises(ValueError):repo_id(value)
|
||||||
|
def test_newest_search_is_sorted_upstream_and_returns_creation_date(self):
|
||||||
|
from urllib.parse import urlsplit,parse_qs
|
||||||
|
with tempfile.TemporaryDirectory() as d,patch('catalog.metadata',return_value=[{'id':'owner/new','createdAt':'2026-09-28T00:00:00Z'}]) as fetch:
|
||||||
|
result=Catalog(d).search('qwen','image','newest')
|
||||||
|
query=parse_qs(urlsplit(fetch.call_args.args[0]).query)
|
||||||
|
self.assertEqual(query['sort'],['createdAt']);self.assertEqual(query['direction'],['-1'])
|
||||||
|
self.assertEqual(result['models'][0]['created_at'],'2026-09-28T00:00:00Z')
|
||||||
|
with self.assertRaises(ValueError):Catalog(d).search('','image','invalid')
|
||||||
def test_download_integrity_and_library(self):
|
def test_download_integrity_and_library(self):
|
||||||
for valid in (True,False):
|
for valid in (True,False):
|
||||||
with self.subTest(valid=valid),tempfile.TemporaryDirectory() as directory:
|
with self.subTest(valid=valid),tempfile.TemporaryDirectory() as directory:
|
||||||
|
|||||||
+9
-2
@@ -3,18 +3,25 @@ from pathlib import Path
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch,Mock
|
from unittest.mock import patch,Mock
|
||||||
from image_test import ImageTests,workflow
|
from image_test import ImageTests,workflow,select_gpus
|
||||||
|
|
||||||
class ImageTestTests(unittest.TestCase):
|
class ImageTestTests(unittest.TestCase):
|
||||||
def test_workflow_uses_selected_profile_and_owned_aliases(self):
|
def test_workflow_uses_selected_profile_and_owned_aliases(self):
|
||||||
p=dict(width=512,height=768,steps=12,guidance=1.5)
|
p=dict(width=512,height=768,steps=12,guidance=1.5)
|
||||||
graph=workflow('benign test',p,99)
|
graph=workflow('benign test',p,99)
|
||||||
self.assertEqual(graph['1']['inputs']['unet_name'],'model.gguf')
|
self.assertEqual(graph['1']['inputs']['unet_name'],'model.gguf')
|
||||||
self.assertEqual(graph['2']['inputs']['device'],'cpu')
|
self.assertEqual(graph['2']['class_type'],'DeckTextEncoderLoader')
|
||||||
self.assertEqual(graph['6']['inputs']['seed'],99)
|
self.assertEqual(graph['6']['inputs']['seed'],99)
|
||||||
self.assertEqual(graph['6']['inputs']['steps'],12)
|
self.assertEqual(graph['6']['inputs']['steps'],12)
|
||||||
self.assertEqual(graph['6']['inputs']['cfg'],1.5)
|
self.assertEqual(graph['6']['inputs']['cfg'],1.5)
|
||||||
self.assertEqual(graph['5']['inputs']['height'],768)
|
self.assertEqual(graph['5']['inputs']['height'],768)
|
||||||
|
def test_two_gpu_selection_rejects_busy_or_small_encoder_gpu(self):
|
||||||
|
devices=[dict(name='NVIDIA GeForce RTX 3060',uuid='encoder',processes=0,free_mib=11962),dict(name='NVIDIA GeForce RTX 5080',uuid='image',processes=0,free_mib=15923)]
|
||||||
|
image,encoder=select_gpus(devices,7*1024**3,9*1024**3,700*1024**2)
|
||||||
|
self.assertEqual((image['uuid'],encoder['uuid']),('image','encoder'))
|
||||||
|
for change in (dict(processes=1),dict(free_mib=7000)):
|
||||||
|
with self.assertRaises(ValueError):select_gpus([dict(devices[0],**change),devices[1]],7*1024**3,9*1024**3,700*1024**2)
|
||||||
|
with self.assertRaises(ValueError):select_gpus([devices[0],dict(devices[1],processes=1)],7*1024**3,9*1024**3,700*1024**2)
|
||||||
def test_missing_runtime_and_invalid_prompts_do_not_spawn(self):
|
def test_missing_runtime_and_invalid_prompts_do_not_spawn(self):
|
||||||
with tempfile.TemporaryDirectory() as d,patch('image_test.PYTHON',Path(d)/'missing'),patch('image_test.subprocess.Popen') as spawn:
|
with tempfile.TemporaryDirectory() as d,patch('image_test.PYTHON',Path(d)/'missing'),patch('image_test.subprocess.Popen') as spawn:
|
||||||
manager=ImageTests(d,Mock())
|
manager=ImageTests(d,Mock())
|
||||||
|
|||||||
Reference in New Issue
Block a user