From a0941c05496c14117723963f2f6d610400dba19a Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:27:23 +0200 Subject: [PATCH] Sort newest Hub models and run image encoder on second GPU --- README.md | 14 ++++-- app.js | 2 +- catalog-ui.js | 8 ++-- catalog.py | 7 +-- deploy/Dockerfile | 2 +- deploy/NATIVE_INSTALL_REQUIREMENTS.md | 6 ++- deploy/install.py | 4 +- docs/NATIVE_APPLICATION_AUDIT.md | 64 +++++++++++++++++++++++++++ image-test-ui.js | 4 +- image_encoder_node.py | 23 ++++++++++ image_test.py | 37 ++++++++++------ server.py | 2 +- studio.js | 2 +- test_catalog.py | 8 ++++ test_image_test.py | 11 ++++- 15 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 docs/NATIVE_APPLICATION_AUDIT.md create mode 100644 image_encoder_node.py diff --git a/README.md b/README.md index 32f014b..22bb84c 100644 --- a/README.md +++ b/README.md @@ -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`. Die Hardware-Seite zeigt GPU-Rechenlast und VRAM-Belegung mit separaten Balken. -Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder -arbeitet auf CPU/System-RAM; GPU-Last kann daher während eines Auftrags null -sein. Nach Abschluss beendet Deck den eigenen Worker und gibt dessen VRAM frei. +Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder arbeitet auf der RTX 3060, Bildmodell und VAE auf der +RTX 5080. CPU/System-RAM bleiben für Laden und Offload beteiligt. Beide GPUs +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). diff --git a/app.js b/app.js index e9f9d10..d7a0da5 100644 --- a/app.js +++ b/app.js @@ -12,7 +12,7 @@ if(['chat','image','audio','video','runtime','runtimes','image-runtime'].include if(page==='access'){await accessPage();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.')+`
Anwendung
Bereit

Athena Deck läuft auf ${esc(s.location)}.

Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert

Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.

Keine Modell- oder Router-Steuerung angebunden

Demo-Dienst

LOKAL / CPU

Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.

${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}
Lädt keine Modelle · endet beim Beenden von Athena Deck
`; -}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+=`
Hardware nicht verfügbar

${esc(h.errors.join(' '))}

`;else{const c=h.cpu,r=h.ram;html+=`
CPU

${esc(c.name)}

${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}
${bar(c.percent)}
System-RAM

Arbeitsspeicher

${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'))}
${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}
`;for(const g of h.gpus)html+=`
GPU ${g.index}${fmt(g.temperature_c,' °C')}

${esc(g.name)}

${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'))}
GPU-Rechenlast${bar(g.percent)}VRAM-Belegung${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}
`;if(h.gpus.length<2)html+='
Nicht alle zwei GPUs verfügbar.
';html+=`

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.
${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}
${esc(h.errors.join(' '))}

`;} +}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+=`
Hardware nicht verfügbar

${esc(h.errors.join(' '))}

`;else{const c=h.cpu,r=h.ram;html+=`
CPU

${esc(c.name)}

${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}
${bar(c.percent)}
System-RAM

Arbeitsspeicher

${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'))}
${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}
`;for(const g of h.gpus)html+=`
GPU ${g.index}${fmt(g.temperature_c,' °C')}

${esc(g.name)}

${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'))}
GPU-Rechenlast${bar(g.percent)}VRAM-Belegung${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}
`;if(h.gpus.length<2)html+='
Nicht alle zwei GPUs verfügbar.
';html+=`

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.
${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}
${esc(h.errors.join(' '))}

`;} }else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`
+

Platz für den nächsten Schritt.

${p[2]}

Platzhalter · noch keine Funktionen

Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.

`;} 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;}} diff --git a/catalog-ui.js b/catalog-ui.js index 04a67f9..6273b5b 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -10,7 +10,7 @@ window.CatalogUI = (() => { function html(library, downloads=false) { return `
HUGGING FACE · LIVE-KATALOG

${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}

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

- ${downloads ? '' : library ? '
' : `
Beliebte Modelle
01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN

Welches Modell interessiert dich?

Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.

Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.

`}
`; + ${downloads ? '' : library ? '
' : `
Beliebte Modelle
01 ENTDECKEN → 02 AUSWÄHLEN → 03 LADEN

Welches Modell interessiert dich?

Wähle ein Ergebnis, um Dateien, Varianten und Lizenz zu prüfen.

Downloads belegen Festplattenspeicher. Sie starten kein Modell. Für Bild, Audio und Video sind häufig mehrere Dateien und ein passender Worker erforderlich.

`}`; } function bind(kind, library, downloads=false) { 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 installed = file => detail && state.entries.some(x => x.repo === detail.repo && x.revision === detail.revision && x.file === file.name); 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-results').innerHTML = sorted.map(m => ``).join('') || '

Keine Treffer

Versuche einen anderen Namen. Die Suche ist auf den aktuellen Bereich begrenzt.

'; root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo)); @@ -45,7 +45,7 @@ window.CatalogUI = (() => { } async function search(q) { 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); } } async function enrich(sequence){ @@ -90,7 +90,7 @@ window.CatalogUI = (() => { } catch(error) { if (sequence === detailSequence) { el('hub-files').innerHTML = '

Details konnten nicht geladen werden. Wähle das Modell erneut, um es noch einmal zu versuchen.

'; message(error.message); } } } 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(); } return {html,bind}; diff --git a/catalog.py b/catalog.py index 9c25858..980c4c9 100644 --- a/catalog.py +++ b/catalog.py @@ -86,10 +86,11 @@ class Catalog: 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.') 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.') - rows=metadata('/api/models?'+urllib.parse.urlencode(dict(search=q,filter=KINDS[kind],limit=20,sort='downloads',direction=-1))) - return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False)) for x in rows]} + if sort not in ('downloads','name','newest'):raise ValueError('Ungültige Sortierung.') + 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): repo=repo_id(repo) with self.cache_lock: diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 4177254..a4376a0 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 WORKDIR /app 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 network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ diff --git a/deploy/NATIVE_INSTALL_REQUIREMENTS.md b/deploy/NATIVE_INSTALL_REQUIREMENTS.md index 25d221a..21dde3b 100644 --- a/deploy/NATIVE_INSTALL_REQUIREMENTS.md +++ b/deploy/NATIVE_INSTALL_REQUIREMENTS.md @@ -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`. PyTorch 2.11.0 CUDA 12.8 benötigt kompatiblen bestehenden Treiber. Keine 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. + +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. diff --git a/deploy/install.py b/deploy/install.py index 6166aa5..8b580e7 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['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): @@ -186,7 +186,7 @@ def update(base, rollback=False): backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') 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'] if was_running:run('docker','stop','--time','15',config['name']) run('docker','rename',config['name'],backup_name) diff --git a/docs/NATIVE_APPLICATION_AUDIT.md b/docs/NATIVE_APPLICATION_AUDIT.md new file mode 100644 index 0000000..6527926 --- /dev/null +++ b/docs/NATIVE_APPLICATION_AUDIT.md @@ -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. diff --git a/image-test-ui.js b/image-test-ui.js index b9510b2..5605ec6 100644 --- a/image-test-ui.js +++ b/image-test-ui.js @@ -1,12 +1,12 @@ window.ImageTestUI=(()=>{ 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;} - function html(readonly=false){return `

${readonly?'Bildlaufzeit':'Bildmodell testen'}

Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.

${readonly?'':`

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.

`}

Textencoder: CPU / System-RAM · Diffusionsmodell und VAE: GPU / VRAM. GPU-Last entsteht nicht während aller Verarbeitungsschritte. Aktuelle Hardware-Messwerte →

`;} + function html(readonly=false){return `

${readonly?'Bildlaufzeit':'Bildmodell testen'}

Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.

${readonly?'':`

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.

`}

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. Aktuelle Hardware-Messwerte →

`;} function bind(readonly=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;}; 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?`${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}

${e(j.profile_name)}

${e(j.phase)}

${e(j.gpu)} · Seed ${e(j.seed)}

${j.state==='running'?'':''}`:`

${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}

Noch kein Bildtest gestartet.

`; + 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?`${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}

${e(j.profile_name)}

${e(j.phase)}

${e(j.gpu)}${j.encoder_gpu?' · Textencoder: '+e(j.encoder_gpu):''} · Seed ${e(j.seed)}

${j.state==='running'?'':''}`:`

${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}

Noch kein Bildtest gestartet.

`; el('image-test-result').innerHTML=j?.state==='complete'?`
Ergebnis des Bildtests
PNG herunterladen
`:''; 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);}} diff --git a/image_encoder_node.py b/image_encoder_node.py new file mode 100644 index 0000000..dd998e2 --- /dev/null +++ b/image_encoder_node.py @@ -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'} diff --git a/image_test.py b/image_test.py index e7f67f9..127a228 100644 --- a/image_test.py +++ b/image_test.py @@ -25,10 +25,18 @@ def probe(): 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())] +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): return { '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'}}, '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}}, @@ -87,26 +95,25 @@ class ImageTests: if raw!='max' and int(raw)-currentneeded] - 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) + gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size']);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.') 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._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,model,encoder,vae),daemon=True).start() + 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,encoder_gpu,model,encoder,vae),daemon=True).start() 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 try: directory.mkdir(mode=0o700) 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)) 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. 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')] with self.lock: if self.cancel.is_set():raise InterruptedError() @@ -120,8 +127,10 @@ class ImageTests: def guard(): if self.cancel.is_set():raise InterruptedError() 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) - if not current or current['processes']>1:raise ValueError('GPU wird inzwischen von einem weiteren Prozess verwendet. Deck-Test beendet.') + current={g['uuid']:g for g in probe()} + 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 while True: guard() @@ -130,9 +139,9 @@ class ImageTests: if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.') time.sleep(1) 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.') - 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'] deadline=time.monotonic()+1800 while time.monotonic(){ document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);}); } function runtimeOverview(){return `
EINSTELLUNGEN

Laufzeiten

Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

llama.cpp

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

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

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

Bildgenerierung

ComfyUI + ComfyUI-GGUF

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.

Textencoder läuft auf CPU, Diffusionsmodell und VAE auf einer freien GPU. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.

Installationsstatus wird geprüft …

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} + function imageRuntime(){return `
EINSTELLUNGEN / LAUFZEITEN

Bildgenerierung

ComfyUI + ComfyUI-GGUF

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.

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.

Installationsstatus wird geprüft …

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} function openProfile(page,modelId){render(page,true,modelId);} return {render,openProfile}; })(); diff --git a/test_catalog.py b/test_catalog.py index cb25bd1..cfe4311 100644 --- a/test_catalog.py +++ b/test_catalog.py @@ -12,6 +12,14 @@ class CatalogTests(unittest.TestCase): with self.assertRaises(ValueError):safe_url(value) for value in ['../foo','a/b/c','https://x','a/..']: 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): for valid in (True,False): with self.subTest(valid=valid),tempfile.TemporaryDirectory() as directory: diff --git a/test_image_test.py b/test_image_test.py index 68ecabc..d17db49 100644 --- a/test_image_test.py +++ b/test_image_test.py @@ -3,18 +3,25 @@ from pathlib import Path import tempfile import unittest from unittest.mock import patch,Mock -from image_test import ImageTests,workflow +from image_test import ImageTests,workflow,select_gpus class ImageTestTests(unittest.TestCase): def test_workflow_uses_selected_profile_and_owned_aliases(self): p=dict(width=512,height=768,steps=12,guidance=1.5) graph=workflow('benign test',p,99) 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']['steps'],12) self.assertEqual(graph['6']['inputs']['cfg'],1.5) 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): with tempfile.TemporaryDirectory() as d,patch('image_test.PYTHON',Path(d)/'missing'),patch('image_test.subprocess.Popen') as spawn: manager=ImageTests(d,Mock())