Compare commits
27
Commits
fb78259668
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
088f5f6f91 | ||
|
|
433e6709cf | ||
|
|
b5ee7c1e01 | ||
|
|
7826388cde | ||
|
|
508a3f4176 | ||
|
|
66e974291d | ||
|
|
9add6d0547 | ||
|
|
69927ca5e6 | ||
|
|
6c3658bbfa | ||
|
|
66b035c8d7 | ||
|
|
c3db35dada | ||
|
|
f66e7fbf71 | ||
|
|
88c6957dac | ||
|
|
7b96d4b3bc | ||
|
|
a72ebe045a | ||
|
|
e16e3dea38 | ||
|
|
df2d55c838 | ||
|
|
5c458bff06 | ||
|
|
a603a223c4 | ||
|
|
cf3e354434 | ||
|
|
3aa2301a53 | ||
|
|
0ca40bc624 | ||
|
|
94bfb1ac78 | ||
|
|
b731811482 | ||
|
|
d700c68394 | ||
|
|
1dcd71b128 | ||
|
|
487b4e1ea5 |
@@ -13,8 +13,10 @@ Zugangsdaten fest:
|
||||
Kennwort und Token dürfen nicht identisch sein. Der Token kann während der Eingabe
|
||||
angezeigt/kopiert werden. **Vor dem Speichern im Passwortmanager sichern.** Ein
|
||||
vorhandener Token ist anschließend nicht abrufbar; bei Verlust einen neuen setzen.
|
||||
Auf HTTP-Adressen kann die Browser-Zwischenablage gesperrt sein. Dann „Token anzeigen“
|
||||
verwenden und manuell kopieren. Im ersten Schritt wird noch keine Sitzung erzeugt:
|
||||
„Neuen Token kopieren“ ist nur bei gefülltem Eingabefeld verfügbar und kopiert
|
||||
ausschließlich diesen neuen Wert, niemals den gespeicherten Prüfwert. Bei gesperrter
|
||||
Clipboard-API wird die browserinterne Kopieralternative versucht. Lehnt der Browser
|
||||
auch diese ab, „Token anzeigen“ verwenden und manuell kopieren. Im ersten Schritt wird noch keine Sitzung erzeugt:
|
||||
nach dem Einrichten mit dem gewählten Kennwort anmelden.
|
||||
|
||||
Der Debian-Installer richtet die Zugangsdaten vor dem Start ein. Speicherung unter
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Alter Athena Router und Athena Deck
|
||||
|
||||
Stand 30.09.2026. Vergleich der aktuellen Quellen und dokumentierten Prüfungen;
|
||||
keine neue vollständige Live-Abnahme sämtlicher Funktionen. Installierte alte
|
||||
Container bedeuten keine Anbindung an Deck. Kategorien und Docker-Listen allein
|
||||
sind kein ausführbarer Worker.
|
||||
|
||||
| Funktion | Athena Deck | Offener Umfang |
|
||||
|---|---|---|
|
||||
| Chat, Profilwechsel, Streaming, Tools, Vision | vorhanden | weitere Client-/Modellkombinationen prüfen |
|
||||
| Kontext, Slots, GPU-Split, Batch, KV-Quantisierung, MTP | vorhanden | Modellabhängigkeit beachten |
|
||||
| Bildgenerierung und bis vier Referenzbilder | vorhanden | Referenzbildpfad nicht universell für alle Familien |
|
||||
| Qwen PE-T2I/PE-I2I Prompt Enhancer | vorhanden | modellabhängige Auswahl und Speicherbedarf |
|
||||
| TTS, STT und warme/dynamische Audio-Policy | vorhanden | nicht jede Sprachmodellfamilie ausführbar |
|
||||
| LTX Original + DeskWEB, exklusiver Videomodus | vorhanden, Basisgeneration geprüft | Extend/Retake, Audio, LoRAs und alle Desktop-Erweiterungen nicht vollständig geprüft |
|
||||
| Dashboard, History, Themes | vorhanden | kein neuer vollständiger Migrationsaudit |
|
||||
| Modellkatalog, Komponenten, Downloads, automatische Speicheranpassung | vorhanden | Kompatibilitätsrezepte nicht universell |
|
||||
| ACE-Step Musikstudio | fehlt als Deck-Worker | Laufzeit, Profile/API, GPU-Modus und Oberfläche |
|
||||
| YuE2 Musikgenerierung | fehlt als Deck-Worker | Laufzeit, Profile/API, GPU-Modus und Oberfläche |
|
||||
| BS-RoFormer Stem-Separation | fehlt | Laufzeit und Job-/Dateiverarbeitung |
|
||||
| OmniVoice Voice Studio | fehlt | eigene Worker-/UI-Anbindung |
|
||||
| X-VC Voice Changer | fehlt | eigene Worker-/UI-Anbindung |
|
||||
| Applio/RVC Konvertierung und Training | fehlt | eigene Worker-/UI-Anbindung |
|
||||
| TRELLIS.2 3D | fehlt | eigene Laufzeit, GPU-Modus und Oberfläche |
|
||||
| EmbeddingGemma / Embeddings | fehlt im Deck-Endpunkt | Embedding-Worker und Route |
|
||||
| Realtime Voice / WebRTC / OpenClaw Talk | fehlt in Deck | Brücke, Echtzeitaudio und Clientintegration |
|
||||
| Responses API | fehlt in Deck | /v1/responses-Kompatibilität |
|
||||
| PCM-Streaming für TTS | fehlt in Deck | Streaming-Audiopfad |
|
||||
| Spezialmodi für Musik/Voice/3D | fehlt | mehr als die vorhandenen LLM-/Video-Modi |
|
||||
| Markierte zusätzliche Docker-Anwendungen | vorhanden | allein noch keine GPU-Worker-Orchestrierung |
|
||||
| WireGuard-Einstellungen | implementiert, im Athena-Test deaktiviert | Ersatz des vorhandenen Gateway nicht migriert/abgenommen |
|
||||
| Backup/Restore | implementiert | kompletter Neuaufbau auf leerem Debian nicht durchgetestet; Anwendungsdaten zusätzlicher Studios brauchen Rezepte |
|
||||
| Nativer Debian-/systemd-Installer | unvollständig | frischer Host, Voraussetzungen und vollständige Abnahme |
|
||||
|
||||
Empfohlene Reihenfolge: Embeddings und API-Lücken; gemeinsame Steuerung weiterer
|
||||
GPU-Spezialworker; Musik/Voice/3D je nach Bedarf; native Installation und vollständiger
|
||||
Restore-Test. LTX-spezifische optionalen Funktionen separat gegen DeskWEB prüfen.
|
||||
|
||||
Die zuvor angefragte Löschung der unbenutzten alten Video-Laufzeit und ihrer
|
||||
22 Backup-Kopien ist noch nicht ausgeführt: die konkrete Bestätigung steht aus.
|
||||
+2
-1
@@ -4,7 +4,8 @@ Eigenständige Installation ohne WireGuard-Modul und ohne Änderungen am bestehe
|
||||
Athena-Router. Der Installer installiert den aktuellen Deck-Anwendungsstand;
|
||||
Modellkatalog, Downloads und llama.cpp-Buildverwaltung sind aktiv. Das Docker-Image
|
||||
enthält das CUDA-Build-Toolkit; der Debian-Host wird nicht verändert. Zielprodukt
|
||||
bleibt ein nativer systemd-Dienst. Modellstarts sind noch nicht angebunden.
|
||||
bleibt ein nativer systemd-Dienst. Modellstarts sind über die angebundenen
|
||||
LLM-, Bild-, Sprach- und Video-Worker verfügbar.
|
||||
|
||||
## Ziel: native Installation auf frischem Debian
|
||||
|
||||
|
||||
@@ -112,3 +112,16 @@ Triton- und Torch-Caches sowie temporäre Dateien liegen nun unter
|
||||
`video/original-work`, auf dem Datenträger statt im kleinen tmpfs.
|
||||
Der Docker-Helfer erhält sein Socket-Verzeichnis über Dienstneustarts hinweg,
|
||||
damit dessen Verzeichnis-Mount im Deck-Testcontainer gültig bleibt.
|
||||
|
||||
## Rückbau des alten Studio-Containers am 30.09.2026
|
||||
|
||||
Nach ausdrücklicher Freigabe entfernt: Container `mike-ai-ltx2-studio`,
|
||||
Image `mike-ai/ltx2-studio:1.2.7` sowie unter `/data/video/ltx-desktop`
|
||||
die alten Verzeichnisse `LTXDesktop/models`, `LTXDesktop/python`, `cache`,
|
||||
`home/.nv` und `home/.triton`. Das alte Compose-Manifest wurde zu
|
||||
`compose.yaml.retired-20260930` umbenannt. Historischer Quellcode bleibt erhalten.
|
||||
Alte Projekte, Eingaben und Ausgaben wurden nicht entfernt. Die neue Deck-Laufzeit,
|
||||
Deck-Gewichte und DeskWEB bleiben erhalten; beide Oberflächen antworteten mit HTTP 200.
|
||||
Der freie Hostspeicher stieg dabei von ungefähr 17 auf 19 GiB; die großen
|
||||
Verzeichnisgrößen dürfen nicht als exklusiv freigegebener Speicher ausgelegt werden.
|
||||
Weitere Backups oder gemeinsame Daten wurden nicht gelöscht.
|
||||
|
||||
@@ -7,6 +7,28 @@ Die Navigation beginnt mit dem [Dashboard](DASHBOARD.md). Es zeigt die
|
||||
Live-Telemetrie und die aus dem alten Athena-Dashboard übernommene History;
|
||||
die frühere separate Hardware-Seite wurde entfernt.
|
||||
|
||||
## Navigation und Detailansichten
|
||||
|
||||
Die **Übersicht** enthält Dashboard und Steuerung (Endpunkt und GPU-Modi).
|
||||
**KI-Werkzeuge** trennt Chat & Sprachmodelle, Bildgenerierung, Musikgenerierung,
|
||||
Sprachausgabe, Spracherkennung, Stimmwerkzeuge und Videogenerierung.
|
||||
Zusätzliche Oberflächen stehen unter **Anwendungen → Weitere Dienste**.
|
||||
|
||||
Unter **Einstellungen → Laufzeiten** führt eine aufklappbare Liste zu den
|
||||
Installations-, Versions- und Statusseiten. Der Laufzeiten-Katalog zeigt den
|
||||
aktuellen Installationsstatus, lässt sich nach Name und Bereich filtern und
|
||||
bietet eine direkte llama.cpp-Releaseprüfung mit Änderungsnotizen. „Build
|
||||
vorbereiten“ übernimmt das Release in das Buildformular; erst dessen
|
||||
Bestätigung startet den Build. Audio Separator besitzt einen eigenen Installer und WAV-Trenntest. Unterstützte
|
||||
BS-/MelBand-RoFormer-Pakete werden einschließlich passender YAML-Konfiguration
|
||||
geladen; ungeprüfte Hub-Dateien werden dadurch nicht freigeschaltet. Die einzelnen Laufzeiten stehen
|
||||
nicht mehr separat in der Seitenleiste. Bibliothek und Profile verwenden
|
||||
kompakte, aufklappbare Zeilen. Bibliotheken gruppieren die Dateien nach Hauptgewichten, Vision-/Audio-Projektoren, Textencodern, VAE, Konfigurationen und weiteren Zusatzkomponenten; nur vorhandene Gruppen werden angezeigt. Unbekannte Rollen bleiben separat sichtbar. In den Zeilen: Details, API-Freigabe, Komponenten und Aktionen
|
||||
stehen in der geöffneten Zeile. Downloads behalten Fortschritt, Geschwindigkeit,
|
||||
Restzeit und Warteschlangenaktionen. Der laufende Betrieb wird zentral unter
|
||||
**Steuerung** angezeigt; Testbereiche behalten ihre jeweiligen Formulare.
|
||||
Bestehende URL-Fragmente bleiben gültig.
|
||||
|
||||
## Vorläufige Testinstallation auf Debian
|
||||
|
||||
Zielprodukt: nativer systemd-Dienst auf Debian. Der derzeitige Docker-Installer dient ausschließlich der isolierten Testphase.
|
||||
@@ -200,3 +222,19 @@ Für reine App-Updates kann der Debian-Installer mit `--update --reuse-runtime`
|
||||
Optionales SwarmUI mit vorhandener ComfyUI-Laufzeit: [Installation und Betrieb](deploy/swarm-ui/README.md).
|
||||
|
||||
Backup und Wiederherstellung unter **Einstellungen → Backup & Restore**: [Umfang, Bedienung, API und Grenzen](BACKUP.md).
|
||||
|
||||
## LadyPoly YuE2
|
||||
|
||||
Die optionale LadyPoly-WebUI läuft als markierter Anwendungscontainer unter
|
||||
Weitere Dienste und nutzt Decks Musikworker statt einer eigenen GPU-Laufzeit.
|
||||
Installation, Verbindung und Einschränkungen: [LadyPoly](deploy/ladypoly/README.md).
|
||||
|
||||
Sprachmodell-GPUs und Vision-Projektor werden unabhängig zugeordnet. Der Modell-Split verteilt ausschließlich LLM-Gewichte; eine zusätzliche Projektor-GPU wird nur für mmproj sichtbar gemacht, nicht für Modell-Offload. Die Speicherprüfung berücksichtigt beide Geräte einschließlich Projektorbedarf.
|
||||
|
||||
Referenzbilder: Alle derzeit ausführbaren Bildrezepte (Qwen Image 2.1 und FLUX.2 Klein 9B) erlauben bis zu vier Bilder pro Bearbeitungsauftrag. FLUX nutzt VAEEncode und verkettete ReferenceLatent-Nodes für beide Conditioning-Zweige nach dem offiziellen ComfyUI-Workflow: https://github.com/Comfy-Org/workflow_templates/blob/main/templates/image_flux2_klein_image_edit_9b_distilled.json . Neue Modellfamilien benötigen einen passenden Workflow; hochgeladene Referenzen werden niemals stillschweigend verworfen.
|
||||
|
||||
Bildadapter werden zentral in `profiles.IMAGE_ADAPTERS` definiert: Modellfamilie, unterstütztes Gewichtsformat, Komponenten, Laufzeit, Workflow, Referenzbildlimit und Speicherpolitik. Bibliothek, Profile und Bildworker verwenden denselben Resolver `image_adapter`. Bekannte Quellen bleiben kompatibel; neue Varianten können über gespeicherte präzise Architektur-Metadaten oder die Hugging-Face-Basismodell-Zuordnung dieselbe Anbindung wiederverwenden. Ein Name mit „Qwen“ oder „Flux“ genügt nicht. Allgemeine FLUX-Architekturangaben unterscheiden Klein 9B nicht sicher von anderen FLUX-Varianten und schalten daher keinen Worker frei. Metadaten sind eine Zuordnungshilfe, keine Garantie für den Inhalt oder die Qualität veränderter Gewichte. Neue Architekturen und Dateiformate benötigen einen registrierten Adapter. Bereits heruntergeladene Varianten ohne ausreichende Metadaten bleiben ungeprüft; bestehende bekannte Profile bleiben erhalten.
|
||||
|
||||
### Audio-Trennung
|
||||
|
||||
Eigener Werkzeugbereich `#separation` mit BS-Roformer, MelBand und Demucs FT/6s. Die alte Web-GUI läuft optional als CPU-only Container unter Weitere Dienste und nutzt den nativen Deck-Worker. [Installation und Grenzen](deploy/separator-web/README.md). Modellquellen, alle Ensemble-Dateien und SHA-256 sind in `separator_sources.json` festgelegt. Paket-Downloads und Backup-Restore verwenden diese geprüften Rezepte; Nutzmedien werden nicht exportiert. Bei lokalen Bibliotheksdateien wird eine belegte Originalquelle separat vom Übernahmeort angezeigt, ohne IDs oder Profilreferenzen zu ändern. Unbekannte lokale Quellen werden nicht geraten.
|
||||
|
||||
@@ -253,3 +253,82 @@ Die handy-computer-Konvertierung verwendet `qwen3_asr`, das der auf Athena getes
|
||||
**STT → Testen**: Profil wählen, Audiodatei auswählen, Sprache (Deutsch, Englisch, automatisch) wählen und transkribieren. Der Browser dekodiert maximal 20 MiB / 120 Sekunden und wandelt in PCM16-Mono-WAV bei 16 kHz um. Eingabeformate hängen von den Browser-Codecs ab. Abbrechen beendet ausschließlich den eigenen STT-Prozess. CPU-Ausführung mit zwei Threads, keine GPU-Belegung; mindestens 4 GiB freier Deck-RAM werden vorausgesetzt. Ein API-Schlüssel schützt den kurzlebigen internen Loopback-Worker. Audio und Transkript werden nicht persistiert; das letzte Transkript liegt bis zum nächsten Auftrag oder Neustart im Speicher und ist nach Anmeldung einsehbar. Keine Mikrofonaufnahme, Zeitstempel oder Sprechertrennung in dieser Version.
|
||||
|
||||
Interne API: `GET /api/v1/stt`, `POST /api/v1/stt/setup` (`{}`), `/assign` (`id`, `revision`), `/start` (Multipart: `profile_id`, `file`, optional `language`), `/cancel` (`{}`). `/setup` verwendet die normale Downloadwarteschlange und startet kein Modell. Ausführung setzt ein vollständiges Profil voraus.
|
||||
|
||||
### Schneller Bibliothekswechsel
|
||||
|
||||
Der Katalog ermittelt den Installationsstatus der Laufzeiten einmal pro Abfrage;
|
||||
für die reine Bibliotheksbewertung wird keine vollständige Video-Modellliste
|
||||
aufgebaut. Das gilt auch für Profilbewertungen und die Dateiauswahl in Entdecken.
|
||||
Die Oberfläche hält den letzten Katalog ausschließlich im Arbeitsspeicher der
|
||||
aktuellen Browserseite. Beim erneuten Öffnen einer Bibliothek erscheinen deren
|
||||
zuletzt geladene Dateien sofort mit Aktualisierungshinweis. Aktionen bleiben bis
|
||||
zur frischen Serverantwort gesperrt; Löschschutz und Profilzuordnungen werden
|
||||
weiterhin serverseitig geprüft. Ein Neuladen der Browserseite leert diesen Cache.
|
||||
|
||||
### Vereinfachte Steuerung
|
||||
|
||||
Unter Steuerung zuerst LLM, Video oder Musik auswählen und anschließend starten.
|
||||
Die Auswahl allein verändert den laufenden Betrieb nicht. Start wechselt bei
|
||||
Bedarf die Betriebsart und öffnet den gemeinsamen API-Listener. Stoppen schließt
|
||||
den Listener und beendet bei Video/Musik den exklusiven Betrieb durch Rückkehr
|
||||
auf LLM; die bisherigen Schutzprüfungen und Wartebedingungen bleiben bestehen.
|
||||
Während eines Wechsels erscheint ein unbestimmter Ladebalken mit der echten
|
||||
Wechselphase, ohne erfundene Prozentangabe. Bereit bedeutet API-erreichbar; der
|
||||
Hinweis zu Gewichten unterscheidet geladene Modelle vom Laden bei Anfrage.
|
||||
API-Adressen und der Port werden für die ausgewählte Betriebsart angezeigt.
|
||||
Technische Konfiguration und Profilfreigaben bleiben unter Einstellungen → Endpunkt.
|
||||
|
||||
Für die derzeit unterstützten YuE2-Musikprofile führt die Steuerung direkt zu
|
||||
LadyPoly auf Port 8127, analog zum LTX-DeskWEB-Link bei Video. Die Oberfläche
|
||||
kann bereits vor dem Start geöffnet werden; Generierung benötigt Musikbetrieb.
|
||||
|
||||
### Anwendungscontainer verwalten
|
||||
|
||||
Weitere Dienste bietet Starten, Stoppen und Löschen für Container mit beiden
|
||||
Deck-Anwendungslabels. Der begrenzte Systemhelfer prüft die aktuelle Zuordnung
|
||||
bei jeder Aktion anhand der vollständigen Container-ID. Registrierte Video-
|
||||
Laufzeiten müssen weiter über die Steuerung verwaltet werden. Löschen benötigt
|
||||
einen gestoppten Container und eine Bestätigung; weder Images noch Volumes
|
||||
werden gelöscht. Daten nur in der beschreibbaren Container-Schicht gehen verloren.
|
||||
Es gibt keine pauschalen Stop-, Prune- oder Löschaktionen für den Docker-Stack.
|
||||
|
||||
## Audio Separator
|
||||
|
||||
Unter Einstellungen → Laufzeiten → Audio Separator steht ein nativer Installer
|
||||
für `audio-separator[gpu]==0.47.0` bereit. PyTorch/Torchaudio 2.11.0 mit CUDA 12.8
|
||||
und ONNX Runtime GPU 1.22.0 werden in einer eigenen Python-Umgebung installiert.
|
||||
FFmpeg und libsndfile werden vom Debian-Testinstaller im Deck-Image bereitgestellt.
|
||||
Die Laufzeit wird pro Server, nicht pro Modell installiert.
|
||||
|
||||
Unterstützte Pakete: BS-RoFormer (`model_bs_roformer_ep_317_sdr_12.9755.ckpt`)
|
||||
und MelBand RoFormer (`vocals_mel_band_roformer.ckpt`). „Modell und Konfiguration
|
||||
herunterladen“ verwendet den Audio-Separator-Modellkatalog und lädt die passende
|
||||
YAML mit. Diese Pakete liegen in `separator-runtime/models`; sie werden derzeit
|
||||
auf der Laufzeitseite verwaltet, getrennt von ungeprüften Hub-Downloads in der
|
||||
Bibliothek. CoreML-, ONNX- und Safetensors-Abwandlungen werden nicht automatisch
|
||||
als kompatibel freigegeben. Für unbekannte RoFormer-Dateien zeigt Deck den Weg
|
||||
zu diesem Einrichtungsbereich, behauptet aber keine Kompatibilität.
|
||||
|
||||
Stimmwerkzeuge → Audio trennen bzw. die Laufzeitseite bieten einen Trenntest:
|
||||
16-Bit-PCM-WAV, Mono/Stereo, 8–48 kHz, maximal 30 Sekunden/8 MiB. Der Worker
|
||||
nutzt bevorzugt eine freie RTX 3060, sonst eine freie RTX 5080. Deck koordiniert
|
||||
eigene LLM/TTS-Prozesse über den GPU-Scheduler; fremde Prozesse werden nicht
|
||||
beendet. Video-/Musikmodus sperren diesen Test. Es gibt keinen stillen CPU-Fallback.
|
||||
Nach Abschluss/Abbruch wird der Prozess beendet und die Eingabedatei gelöscht.
|
||||
Ergebnis-WAVs bleiben unter `separator-tests/<job-id>` auf Athena.
|
||||
|
||||
Interne, nur mit GUI-Anmeldung erreichbare API:
|
||||
- `GET /api/v1/separator-runtime`: Status, Installationsauftrag, Modellpakete.
|
||||
- `POST /api/v1/separator-runtime/install` / `cancel`: leeres JSON-Objekt.
|
||||
- `POST /api/v1/separator-runtime/download`: `{"model":"<Paket-Dateiname>"}`.
|
||||
- `GET /api/v1/separator-tests`: Teststatus und Ergebnisliste.
|
||||
- `POST /api/v1/separator-tests/start`: Multipart-Felder `model` und `file`.
|
||||
- `POST /api/v1/separator-tests/cancel`: leeres JSON-Objekt.
|
||||
- `GET /api/v1/separator-tests/audio?id=<job-id>&index=<Spur>`: WAV.
|
||||
|
||||
Backups enthalten Version und ausgewählte Modellpakete als Restore-Rezept,
|
||||
keine Laufzeit-Binaries, Gewichte, Eingabe- oder Ergebnis-Audiodateien. Restore
|
||||
installiert eine fehlende Laufzeit und lädt die Modellpakete erneut. Der
|
||||
Audio-Separator-Upstream-Katalog und seine öffentlichen Modellquellen können
|
||||
sich ändern; Wiederherstellung ist von deren Verfügbarkeit abhängig.
|
||||
Quelle: https://github.com/nomadkaraoke/python-audio-separator/tree/v0.47.0
|
||||
|
||||
+16
-4
@@ -1,16 +1,28 @@
|
||||
async function copyTokenValue(value){
|
||||
if(!value)throw Error('empty');
|
||||
try{if(navigator.clipboard?.writeText){await navigator.clipboard.writeText(value);return;}}catch{}
|
||||
const active=document.activeElement,selection=window.getSelection(),ranges=[];
|
||||
if(selection)for(let i=0;i<selection.rangeCount;i++)ranges.push(selection.getRangeAt(i).cloneRange());
|
||||
const area=document.createElement('textarea');area.value=value;area.readOnly=true;
|
||||
area.style.cssText='position:fixed;left:-9999px;top:0;opacity:0';document.body.append(area);
|
||||
try{area.focus();area.select();if(!document.execCommand('copy'))throw Error('clipboard');}
|
||||
finally{area.value='';area.remove();active?.focus();if(selection){selection.removeAllRanges();for(const range of ranges)selection.addRange(range);}}
|
||||
}
|
||||
let accessBusy=false;
|
||||
function accessToken(){const bytes=crypto.getRandomValues(new Uint8Array(32));return 'ad_'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
|
||||
async function accessPage(){
|
||||
const view=document.querySelector('#view');
|
||||
if(document.querySelector('#access-page'))return;
|
||||
view.innerHTML=`<div id="access-page"><div class="kicker">EINSTELLUNGEN / ZUGANG</div><h1>Dein Zugang. Deine Schlüssel.</h1><p>Ein Administratorzugang für die Oberfläche und ein separater Token für API-Clients.</p><p id="access-info"></p><section class="card"><h2>Oberflächenkennwort ändern</h2><form id="password-form"><label for="current-password">Aktuelles Kennwort</label><input id="current-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-password">Neues Kennwort · mindestens 16 Zeichen</label><input id="new-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><label for="repeat-password">Neues Kennwort wiederholen</label><input id="repeat-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><button>Kennwort ändern</button></form><p class="note">Alle bestehenden Browser-Sitzungen werden abgemeldet. Der API-Token bleibt gültig.</p></section><section class="card"><h2>API-Token ändern</h2><p id="token-info"></p><form id="token-form"><label for="token-password">Aktuelles Oberflächenkennwort zur Bestätigung</label><input id="token-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-token">Neuer API-Token</label><input id="new-token" type="password" autocomplete="off" spellcheck="false" required minlength="32" maxlength="256"><button id="token-generate" type="button" class="secondary">Sicheren Token erzeugen</button><button id="token-show" type="button" class="secondary">Token anzeigen</button><button id="token-copy" type="button" class="secondary">Token kopieren</button><button>Neuen Token speichern</button></form><p class="note">Vor dem Speichern kopieren. Der bisherige Token wird sofort ungültig. Bestehende Browser-Sitzungen bleiben angemeldet. Verwende in API-Clients: Authorization: Bearer <Token>.</p></section><p id="access-message" role="status" aria-live="polite"></p><button id="logout" class="secondary">Abmelden</button><p class="note">Der Token berechtigt zum Dienststatus, Hardwareabruf und Demo-Start/Stopp. Er erlaubt keine Kennwort-, Token- oder Netzwerkeinstellungen. Weitere Benutzer und Rollen sind noch nicht enthalten.</p></div>`;
|
||||
view.innerHTML=`<div id="access-page"><div class="kicker">EINSTELLUNGEN / ZUGANG</div><h1>Dein Zugang. Deine Schlüssel.</h1><p>Ein Administratorzugang für die Oberfläche und ein separater Token für API-Clients.</p><p id="access-info"></p><section class="card"><h2>Oberflächenkennwort ändern</h2><form id="password-form"><label for="current-password">Aktuelles Kennwort</label><input id="current-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-password">Neues Kennwort · mindestens 16 Zeichen</label><input id="new-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><label for="repeat-password">Neues Kennwort wiederholen</label><input id="repeat-password" type="password" autocomplete="new-password" required minlength="16" maxlength="256"><button>Kennwort ändern</button></form><p class="note">Alle bestehenden Browser-Sitzungen werden abgemeldet. Der API-Token bleibt gültig.</p></section><section class="card"><h2>API-Token ändern</h2><p id="token-info"></p><p>Der gespeicherte Token kann nicht zurückgelesen werden. Kopiert wird nur der neue Token im Eingabefeld. Einen zuvor gespeicherten Token findest du in deinem Passwortmanager.</p><form id="token-form"><label for="token-password">Aktuelles Oberflächenkennwort zur Bestätigung</label><input id="token-password" type="password" autocomplete="current-password" required maxlength="256"><label for="new-token">Neuer API-Token</label><input id="new-token" type="password" autocomplete="off" spellcheck="false" required minlength="32" maxlength="256"><button id="token-generate" type="button" class="secondary">Sicheren Token erzeugen</button><button id="token-show" type="button" class="secondary">Token anzeigen</button><button id="token-copy" type="button" class="secondary" disabled>Neuen Token kopieren</button><button>Neuen Token speichern</button></form><p class="note">Vor dem Speichern kopieren. Der bisherige Token wird sofort ungültig. Bestehende Browser-Sitzungen bleiben angemeldet. Verwende in API-Clients: Authorization: Bearer <Token>.</p></section><p id="access-message" role="status" aria-live="polite"></p><button id="logout" class="secondary">Abmelden</button><p class="note">Der Token berechtigt zum Dienststatus, Hardwareabruf und Demo-Start/Stopp. Er erlaubt keine Kennwort-, Token- oder Netzwerkeinstellungen. Weitere Benutzer und Rollen sind noch nicht enthalten.</p></div>`;
|
||||
try{const r=await fetch('/api/v1/auth/status'),s=await r.json();if(!s.authenticated){location.reload();return}document.querySelector('#access-info').textContent=`Administrator · Diese Instanz: ${s.location}`;document.querySelector('#token-info').textContent=s.api_token_configured?('API-Token eingerichtet'+(s.token_changed_at?' · geändert am '+new Date(s.token_changed_at*1000).toLocaleString('de-DE'):'')+'. Der Wert wird nicht angezeigt.'):'Noch kein API-Token eingerichtet. API-Clients haben keinen Zugang.';}catch{document.querySelector('#access-message').textContent='Zugangsstatus nicht erreichbar.'}
|
||||
async function save(kind,data,form){if(accessBusy)return;accessBusy=true;view.querySelectorAll('button').forEach(b=>b.disabled=true);try{const r=await fetch('/api/v1/auth/'+kind,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}),s=await r.json();if(r.status===401){location.reload();return}if(!r.ok)throw Error(s.error);form.reset();if(s.login_required){location.reload();return}document.querySelector('#access-message').textContent='Neuer API-Token gespeichert. Der alte Token ist jetzt ungültig.';document.querySelector('#token-info').textContent='API-Token gerade geändert. Der Wert wird nicht angezeigt.';}catch(e){document.querySelector('#access-message').textContent=e.message}finally{accessBusy=false;view.querySelectorAll('button').forEach(b=>b.disabled=false)}}
|
||||
async function save(kind,data,form){if(accessBusy)return;accessBusy=true;view.querySelectorAll('button').forEach(b=>b.disabled=true);try{const r=await fetch('/api/v1/auth/'+kind,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}),s=await r.json();if(r.status===401){location.reload();return}if(!r.ok)throw Error(s.error);form.reset();if(s.login_required){location.reload();return}document.querySelector('#access-message').textContent='Neuer API-Token gespeichert. Der alte Token ist jetzt ungültig.';document.querySelector('#token-info').textContent='API-Token gerade geändert. Der Wert wird nicht angezeigt.';}catch(e){document.querySelector('#access-message').textContent=e.message}finally{accessBusy=false;view.querySelectorAll('button').forEach(b=>b.disabled=false);syncTokenCopy()}}
|
||||
document.querySelector('#password-form').onsubmit=e=>{e.preventDefault();const value=document.querySelector('#new-password').value;if(value!==document.querySelector('#repeat-password').value){document.querySelector('#access-message').textContent='Die neuen Kennwörter stimmen nicht überein.';return}save('password',{current_password:document.querySelector('#current-password').value,new_password:value},e.target)};
|
||||
document.querySelector('#token-form').onsubmit=e=>{e.preventDefault();save('token',{current_password:document.querySelector('#token-password').value,new_token:document.querySelector('#new-token').value},e.target)};
|
||||
document.querySelector('#token-generate').onclick=()=>{document.querySelector('#new-token').value=accessToken();document.querySelector('#access-message').textContent='Neuen Token vor dem Speichern kopieren.'};
|
||||
const syncTokenCopy=()=>{document.querySelector('#token-copy').disabled=accessBusy||!document.querySelector('#new-token').value;};
|
||||
document.querySelector('#new-token').addEventListener('input',syncTokenCopy);syncTokenCopy();
|
||||
document.querySelector('#token-generate').onclick=()=>{document.querySelector('#new-token').value=accessToken();syncTokenCopy();document.querySelector('#access-message').textContent='Neuen Token vor dem Speichern kopieren.'};
|
||||
document.querySelector('#token-show').onclick=()=>{const input=document.querySelector('#new-token');input.type=input.type==='password'?'text':'password';document.querySelector('#token-show').textContent=input.type==='password'?'Token anzeigen':'Token verbergen';};
|
||||
document.querySelector('#token-copy').onclick=async()=>{try{const token=document.querySelector('#new-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);document.querySelector('#access-message').textContent='Token kopiert. Bitte sicher speichern.';}catch{document.querySelector('#access-message').textContent='Kopieren nicht möglich. Bitte einen eigenen Token aus dem Passwortmanager verwenden.'}};
|
||||
document.querySelector('#token-copy').onclick=async()=>{try{const token=document.querySelector('#new-token').value;if(!token)throw Error();await copyTokenValue(token);document.querySelector('#access-message').textContent='Token kopiert. Bitte sicher speichern.';}catch{document.querySelector('#access-message').textContent='Der Browser hat das Kopieren nicht erlaubt. Bitte „Token anzeigen“ wählen und den neuen Token manuell kopieren.'}};
|
||||
const hub=document.createElement('section');hub.className='card';hub.innerHTML=`<h2>Hugging Face · Modelldownloads</h2><p id="hf-status" role="status">Zugang wird geprüft …</p><p>Für private oder freigabepflichtige Modelle: <a class="link" href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer">Hugging-Face-Token erstellen ↗</a> mit Leserechten für das gewünschte Repository. Die Freigabe beantragst du auf dessen Modellkarte mit demselben Konto.</p><form id="hf-form"><label>Hugging-Face-Token<input id="hf-token" type="password" autocomplete="off" spellcheck="false" required maxlength="255" placeholder="hf_…"></label><button>Token speichern / ersetzen</button><button id="hf-remove" type="button" class="secondary">Gespeicherten Token entfernen</button></form><p class="note">Separater Zugang für Hugging Face, nicht der Deck-API-Token. Speicherung auf Athena in einer Datei mit Zugriff nur für den Dienstbenutzer (0600), unverschlüsselt. Der gespeicherte Wert wird nie an den Browser zurückgegeben. Bereits laufende Downloads können nach Entfernen weiterlaufen.</p>`;
|
||||
document.querySelector('#access-page').insertBefore(hub,document.querySelector('#access-message'));
|
||||
const hfStatus=hub.querySelector('#hf-status');
|
||||
|
||||
@@ -24,6 +24,8 @@ function appearancePage(){
|
||||
sync();
|
||||
}
|
||||
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'dashboard';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
|
||||
if(['separator-runtime','separator-test','separation'].includes(page)){SeparatorUI.render(page!=='separator-runtime');return;}
|
||||
if(page==='audio-cpp-runtime'){AudioCppUI.render();return;}
|
||||
if(page==='ltx-original-runtime'){LTXOriginalUI.render();return;}
|
||||
if(['video','video-runtime'].includes(page)){VideoUI.runtime();return;}
|
||||
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
window.AudioCppUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(path='',data){const r=await fetch('/api/v1/audio-cpp-runtime'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
|
||||
function render(){const view=document.querySelector('#view');if(view.querySelector('#audio-cpp-page'))return;
|
||||
view.innerHTML='<div id="audio-cpp-page"><h1>audio.cpp</h1><p>Gemeinsame C++-/CUDA-Laufzeit für unterstützte Audio-Modellfamilien. Passendes GGUF-Paket und ein Deck-Worker sind zusätzlich erforderlich.</p><section class="card"><h2>Herunterladen und bereitmachen</h2><div id="ac-status"></div><button id="ac-install">audio.cpp herunterladen & CUDA-Build erstellen</button><button id="ac-cancel" class="secondary">Build abbrechen</button><p id="ac-message" role="status"></p><details><summary>Eigene Build-Ausgabe</summary><pre id="ac-log" style="white-space:pre-wrap;max-height:320px;overflow:auto"></pre></details></section><section class="card"><h2>YuE2 und Ladypoly WebUI</h2><p>Modellfamilie erkannt. Für Generierung das Musikprofil und seine Komponenten einrichten, dann Musik → Testen verwenden. Die Ladypoly-Oberfläche ist separat. Dieser Build lädt keine Gewichte, startet kein Modell und gibt kein Profil am Endpunkt frei.</p><a href="#music">Zur Musik-Bibliothek →</a></section></div>';
|
||||
const root=view.firstChild;let timer;
|
||||
async function poll(){if(!root.isConnected)return;try{const s=await api();if(!root.isConnected)return;const running=s.job?.state==='running';root.querySelector('#ac-install').disabled=s.installed||running;root.querySelector('#ac-cancel').disabled=!running;root.querySelector('#ac-status').innerHTML=`<p><strong>${s.installed?'Installiert · YuE2-Musikworker verfügbar':'Noch nicht installiert'}</strong></p><p>Version ${e(s.version)} · Commit <code>${e(s.revision)}</code></p><p>Mindestens ${e(s.required_disk_gib)} GiB freier Speicher. Eigener Build mit sechs Jobs, CUDA-Ziele werden aus den vorhandenen GPUs ermittelt. Bestehende Laufzeiten werden nicht verändert.</p>${s.job?`<p>${e(s.job.state)} · ${e(s.job.phase)}</p>${running?'<progress aria-label="audio.cpp-Build läuft"></progress>':''}`:''}`;if(s.job){const log=await api('/log');if(root.isConnected)root.querySelector('#ac-log').textContent=log.text;}}catch(x){if(root.isConnected)root.querySelector('#ac-message').textContent=x.message;}if(root.isConnected){clearTimeout(timer);timer=setTimeout(poll,3000);}}
|
||||
for(const [id,path] of [['ac-install','/install'],['ac-cancel','/cancel']])root.querySelector('#'+id).onclick=async()=>{root.querySelector('#'+id).disabled=true;try{await api(path,{});root.querySelector('#ac-message').textContent='Aktion angenommen.';}catch(x){root.querySelector('#ac-message').textContent=x.message;}poll();};poll();}
|
||||
return {render};
|
||||
})();
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Pinned audio.cpp build, isolated from model execution and existing runtimes."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from ltx_original_runtime import LTXOriginalRuntime
|
||||
|
||||
REVISION='f2b4937306daa25f5c78520f3c626ed31495a37a'
|
||||
VERSION='v0.8.1'
|
||||
class AudioCppRuntime(LTXOriginalRuntime):
|
||||
name="audio.cpp"
|
||||
def __init__(self,root):
|
||||
super().__init__(root,None)
|
||||
request=self.root/'resume-request'
|
||||
if request.is_file():
|
||||
request.unlink()
|
||||
self.start()
|
||||
def paths(self):
|
||||
p=self.root/'active.json'
|
||||
if p.is_file():
|
||||
ident=json.loads(p.read_text()).get('id','')
|
||||
if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):
|
||||
base=self.root/ident
|
||||
if (base/'ready').is_file():return base/'build/bin/audiocpp_cli',base/'source'
|
||||
return self.root/'missing-cli',self.root/'missing-source'
|
||||
def status(self):
|
||||
cli,source=self.paths()
|
||||
return dict(installed=cli.is_file() and (source/'model_specs/yue2.json').is_file(),revision=REVISION,version=VERSION,job=dict(self.job) if self.job else None,required_disk_gib=6,adapter_ready=True,origin='0xShug0/audio.cpp',message='Gemeinsame Laufzeit. Ein Build startet kein Modell; Der YuE2-Musikworker ist angebunden; Profil und Komponenten werden benötigt. Andere Familien brauchen eigene geprüfte Anbindungen.')
|
||||
def start(self):
|
||||
if sys.platform!='linux':raise ValueError('audio.cpp wird auf dem Debian-Server gebaut.')
|
||||
missing=[name for name in ('git','cmake','g++','nvcc','nvidia-smi') if not shutil.which(name)]
|
||||
if missing:raise ValueError('Build-Werkzeuge fehlen: '+', '.join(missing)+'. Keine automatische Host-Paket- oder Treiberänderung.')
|
||||
self.resume_id=(self.job or {}).get('id')
|
||||
return super().start()
|
||||
def _run(self):
|
||||
base=self.root/self.job['id'];state='failed';phase='audio.cpp-Build fehlgeschlagen.'
|
||||
try:
|
||||
previous=self.root/getattr(self,'resume_id','missing') if getattr(self,'resume_id',None) else None
|
||||
if previous and (previous/'source/.git').is_dir() and not (previous/'ready').exists():
|
||||
base=previous;self.job['id']=previous.name;self._save()
|
||||
else:base.mkdir(mode=0o700)
|
||||
tmp=base/'tmp';tmp.mkdir(exist_ok=True)
|
||||
env=dict(os.environ,TMPDIR=str(tmp),GIT_TERMINAL_PROMPT='0',OMP_NUM_THREADS='2')
|
||||
source=base/'source';build=base/'build'
|
||||
self._phase('Offizielle audio.cpp-Quelle herunterladen')
|
||||
if not (source/'.git').is_dir():
|
||||
self._command(['git','init',source],env)
|
||||
self._command(['git','-C',source,'fetch','--depth','1','https://github.com/0xShug0/audio.cpp.git',REVISION],env)
|
||||
self._command(['git','-C',source,'checkout','--detach','FETCH_HEAD'],env)
|
||||
else:self._command(['git','-C',source,'rev-parse','--verify',REVISION+'^{commit}'],env)
|
||||
# Compile for the actual available GPUs; no weight allocation or inference.
|
||||
import subprocess
|
||||
raw=subprocess.check_output(['nvidia-smi','--query-gpu=compute_cap','--format=csv,noheader'],text=True)
|
||||
arch=sorted({line.strip().replace('.','') for line in raw.splitlines() if line.strip()})
|
||||
if not arch or any(not a.isdigit() for a in arch):raise ValueError('CUDA-Architekturen nicht ermittelbar.')
|
||||
self._phase('CUDA-Build konfigurieren · GPU-Ziele '+', '.join(arch))
|
||||
self._command(['cmake','-S',source,'-B',build,'-DCMAKE_BUILD_TYPE=Release','-DENGINE_ENABLE_CUDA=ON','-DCMAKE_CUDA_ARCHITECTURES='+';'.join(arch),'-DAUDIOCPP_DEPLOYMENT_BUILD=ON','-DAUDIOCPP_BUILD_SERVER_FRONTENDS=OFF','-DENGINE_BUILD_TESTS=OFF'],env)
|
||||
self._phase('audio.cpp bauen · sechs parallele Build-Jobs')
|
||||
self._command(['cmake','--build',build,'--target','audiocpp_cli','audiocpp_server','--parallel','6'],env)
|
||||
self._phase('Programmstart ohne Modell prüfen')
|
||||
self._command([build/'bin/audiocpp_cli','--help'],env)
|
||||
if not (source/'model_specs/yue2.json').is_file():raise ValueError('YuE2-Modellspezifikation fehlt.')
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
(base/'ready').write_text(REVISION)
|
||||
marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json')
|
||||
state='complete';phase='audio.cpp installiert. Kein Modell gestartet; Deck-Worker noch erforderlich.'
|
||||
except InterruptedError:state='cancelled';phase='Build abgebrochen.'
|
||||
except Exception as exc:phase=str(exc)
|
||||
finally:
|
||||
# Keep unfinished compiler artifacts so retry resumes the same pinned build.
|
||||
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
|
||||
@@ -51,7 +51,7 @@ def validate(doc):
|
||||
if kind=='chat' and (params['ubatch']>params['batch'] or params.get('vision_projector') and params['vision_projector'] not in ids):raise ValueError('Ungültige Chat-Zuordnung.')
|
||||
if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Ungültige Bildgröße.')
|
||||
components=p.get('components',{})
|
||||
if not isinstance(components,dict) or any(k not in ('text_encoder','vae','video_vae','audio_vae','spatial_upsampler') or v not in ids for k,v in components.items()):raise ValueError('Ungültige Komponentenreferenz.')
|
||||
if not isinstance(components,dict) or any((k not in ('text_encoder','vae','video_vae','audio_vae','spatial_upsampler','model_config','generation_config','tokenizer','vae_config','manual_text_encoder','manual_vae','manual_tokenizer','manual_configuration','manual_projector','manual_auxiliary') and not re.fullmatch(r'runtime_file_[0-9]{1,2}',k)) or v not in ids for k,v in components.items()):raise ValueError('Ungültige Komponentenreferenz.')
|
||||
pids.add(p['id']);names.add(name)
|
||||
settings=doc.get('settings')
|
||||
if not isinstance(settings,dict) or set(settings)-set(SETTINGS):raise ValueError('Unbekannte Einstellungsdateien.')
|
||||
@@ -72,7 +72,15 @@ def validate(doc):
|
||||
if 'video/original-work/settings.json' in settings and (not isinstance(settings['video/original-work/settings.json'],dict) or len(json.dumps(settings['video/original-work/settings.json']))>65536):raise ValueError('Ungültige LTX-Einstellungen.')
|
||||
validate_record(doc.get('credentials'))
|
||||
runtimes=doc.get('runtimes')
|
||||
if not isinstance(runtimes,dict) or set(runtimes)-{'llama','llama_builds','image','tts','enhancers','swarm_nodes','ltx_original'} or not {'llama','llama_builds','image','tts','enhancers','swarm_nodes'}<=set(runtimes):raise ValueError('Ungültiges Laufzeitrezept.')
|
||||
if not isinstance(runtimes,dict) or set(runtimes)-{'llama','llama_builds','image','tts','enhancers','swarm_nodes','ltx_original','audio_cpp','separator'} or not {'llama','llama_builds','image','tts','enhancers','swarm_nodes'}<=set(runtimes):raise ValueError('Ungültiges Laufzeitrezept.')
|
||||
if 'separator' in runtimes:
|
||||
from separator_runtime import MODELS
|
||||
value=runtimes['separator']
|
||||
if not isinstance(value,dict) or set(value)-{'installed','models','sources'} or not {'installed','models'}<=set(value) or type(value['installed']) is not bool or not isinstance(value['models'],list) or len(value['models'])>len(MODELS) or any(not isinstance(m,str) or m not in MODELS for m in value['models']) or len(set(value['models']))!=len(value['models']):raise ValueError('Ungültiges Audio-Separator-Rezept.')
|
||||
if 'sources' in value:
|
||||
from separator_runtime import SOURCES
|
||||
if value['sources']!={name:SOURCES[name] for name in value['models']}:raise ValueError('Audio-Separator-Quellen stimmen nicht mit dem geprüften Rezept überein.')
|
||||
if 'audio_cpp' in runtimes and type(runtimes['audio_cpp']) is not bool:raise ValueError('Ungültige audio.cpp-Laufzeit.')
|
||||
if 'ltx_original' in runtimes and type(runtimes['ltx_original']) is not bool:raise ValueError('Ungültige LTX-Laufzeit.')
|
||||
if not isinstance(runtimes['llama_builds'],list) or len(runtimes['llama_builds'])>50 or any(not isinstance(b,dict) or set(b)!={'commit','backend'} or not re.fullmatch('[a-f0-9]{40}',b.get('commit','')) or b.get('backend') not in ('CUDA','CPU') for b in runtimes['llama_builds']):raise ValueError('Ungültige Buildrezepte.')
|
||||
if runtimes['llama'] is not None:
|
||||
@@ -137,7 +145,13 @@ class Backup:
|
||||
if not s.network.disabled:
|
||||
network=s.network.call('backup-export')
|
||||
else:warnings.append('Deck-Netzwerkmodul deaktiviert; fremder WireGuard-Gateway wird nicht gesichert.')
|
||||
document=dict(format='athena-deck',version=VERSION,created_at=time.time(),settings=settings,models=models,credentials=s.credentials.read(),theme=theme,network=network,services=services,runtime_versions=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']),runtimes=dict(ltx_original=bool(getattr(s,'ltx_original_runtime',None) and s.ltx_original_runtime.status()['installed']),llama=llama,llama_builds=[dict(commit=b['commit'],backend=b['backend']) for b in state['builds']],image=s.image_runtime.status()['installed'],tts=s.tts_runtime.status()['installed'],enhancers={task:data['revision'] for task in ('t2i','i2i') if (data:=s.prompt_enhancer.installed(task))},swarm_nodes=(self.root/'video/swarm-comfy-nodes').is_dir()),warnings=warnings)
|
||||
document=dict(format='athena-deck',version=VERSION,created_at=time.time(),settings=settings,models=models,credentials=s.credentials.read(),theme=theme,network=network,services=services,runtime_versions=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']),runtimes=dict(audio_cpp=bool(getattr(s,'audio_cpp_runtime',None) and s.audio_cpp_runtime.status()['installed']),ltx_original=bool(getattr(s,'ltx_original_runtime',None) and s.ltx_original_runtime.status()['installed']),llama=llama,llama_builds=[dict(commit=b['commit'],backend=b['backend']) for b in state['builds']],image=s.image_runtime.status()['installed'],tts=s.tts_runtime.status()['installed'],enhancers={task:data['revision'] for task in ('t2i','i2i') if (data:=s.prompt_enhancer.installed(task))},swarm_nodes=(self.root/'video/swarm-comfy-nodes').is_dir()),warnings=warnings)
|
||||
if getattr(s,'separator_runtime',None):
|
||||
from separator_runtime import SOURCES
|
||||
separator=s.separator_runtime.status();document['runtimes']['separator']=dict(installed=separator['installed'],models=[m['id'] for m in separator['models'] if m['installed']],sources={m['id']:SOURCES[m['id']] for m in separator['models'] if m['installed']});document['runtime_versions']['separator']=separator['version']
|
||||
if document['runtimes']['audio_cpp']:
|
||||
from audio_cpp_runtime import REVISION
|
||||
document['runtime_versions']['audio_cpp']=REVISION
|
||||
if document['runtimes']['ltx_original']:
|
||||
from ltx_original_runtime import DESKTOP_REV,LTX_REV
|
||||
document['runtime_versions']['ltx_original']=dict(desktop=DESKTOP_REV,inference=LTX_REV)
|
||||
@@ -167,10 +181,16 @@ class Backup:
|
||||
elif s.network.status().get('enabled') or s.network.status().get('mode')!='lan':blockers.append('Deck-Tunnel vor Netzwerk-Restore im bestätigten LAN-Modus deaktivieren.')
|
||||
if doc['network']['mode']!='lan':warnings.append('Tunnelzugang wird mit Rückfallzeit aktiviert und muss über den Tunnel bestätigt werden.')
|
||||
versions=doc.get('runtime_versions',{})
|
||||
if versions.get('separator'):
|
||||
from separator_runtime import VERSION as SEPARATOR_VERSION
|
||||
if versions['separator']!=SEPARATOR_VERSION:blockers.append('Backup verlangt eine andere Audio-Separator-Version.')
|
||||
if versions.get('audio_cpp'):
|
||||
from audio_cpp_runtime import REVISION
|
||||
if versions['audio_cpp']!=REVISION:blockers.append('Backup verlangt eine andere audio.cpp-Version. Passende Deck-Version verwenden.')
|
||||
if versions.get('ltx_original'):
|
||||
from ltx_original_runtime import DESKTOP_REV,LTX_REV
|
||||
if versions['ltx_original']!=dict(desktop=DESKTOP_REV,inference=LTX_REV):blockers.append('Backup verlangt eine andere originale LTX-Version. Passende Deck-Version verwenden.')
|
||||
if versions and {k:v for k,v in versions.items() if k!='ltx_original'}!=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']):blockers.append('Backup verlangt andere Bild-/TTS-Laufzeitversionen. Passende Deck-Version oder geprüftes Runtime-Upgrade verwenden.')
|
||||
if versions and {k:v for k,v in versions.items() if k not in ('ltx_original','audio_cpp','separator')}!=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']):blockers.append('Backup verlangt andere Bild-/TTS-Laufzeitversionen. Passende Deck-Version oder geprüftes Runtime-Upgrade verwenden.')
|
||||
for c in doc['services']:
|
||||
if not c.get('registry') and c.get('build_recipe')!='swarm-ui':warnings.append(c['name']+': Image nur lokal vorhanden; Registry-/Buildquelle fehlt.')
|
||||
ports=s.endpoint.allowed_ports
|
||||
@@ -195,7 +215,7 @@ class Backup:
|
||||
if len(set(data['services']))!=len(data['services']) or any(name not in services for name in data['services']):raise ValueError('Unbekannter Zusatzdienst.')
|
||||
if any(not services[name].get('registry') and not services[name].get('build_recipe') for name in data['services']):raise ValueError('Gewählter Dienst hat keine Wiederherstellungsquelle.')
|
||||
s=self.server
|
||||
if s.catalog.pending or s.catalog.job and s.catalog.job['state']=='downloading' or s.runtime.busy or any((getattr(o,'job',None) or {}).get('state')=='running' for o in (s.image_runtime,s.tts_runtime,s.ltx_original_runtime,s.prompt_enhancer,s.auto_tests,s.chat_tests,s.image_tests,s.tts_tests,s.stt)):raise ValueError('Zuerst laufende Downloads, Builds und Tests abschließen oder abbrechen.')
|
||||
if s.catalog.pending or s.catalog.job and s.catalog.job['state']=='downloading' or s.runtime.busy or any((getattr(o,'job',None) or {}).get('state')=='running' for o in (s.image_runtime,s.tts_runtime,s.ltx_original_runtime,getattr(s,'audio_cpp_runtime',None),getattr(s,'music',None),getattr(s,'separator_runtime',None),getattr(s,'separator_tests',None),s.prompt_enhancer,s.auto_tests,s.chat_tests,s.image_tests,s.tts_tests,s.stt)):raise ValueError('Zuerst laufende Downloads, Builds und Tests abschließen oder abbrechen.')
|
||||
self.cancel.clear();self.job=dict(id=secrets.token_hex(16),state='running',phase='Wiederherstellung vorbereiten',completed=0,total=len(doc['models'])+len(data['services'])+5,items=[],started_at=time.time(),theme=doc.get('theme','deck'))
|
||||
self._save();threading.Thread(target=self._restore,args=(copy.deepcopy(doc),data['services'],data['restore_credentials']),daemon=True).start();self.plan=None
|
||||
return self.status()
|
||||
@@ -221,8 +241,17 @@ class Backup:
|
||||
self._phase('Vorhandene Bild-/Videolaufzeit prüfen oder installieren');s.image_runtime.start();self._wait(s.image_runtime.status)
|
||||
if r['tts'] and not s.tts_runtime.status()['installed']:
|
||||
self._phase('TTS-Laufzeit und Zusatzdateien installieren');s.tts_runtime.start();self._wait(s.tts_runtime.status)
|
||||
if r.get('audio_cpp') and not s.audio_cpp_runtime.status()['installed']:
|
||||
self._phase('audio.cpp-Laufzeit installieren');s.audio_cpp_runtime.start();self._wait(s.audio_cpp_runtime.status)
|
||||
if r.get('ltx_original') and not s.ltx_original_runtime.status()['installed']:
|
||||
self._phase('Originale LTX-Laufzeit installieren');s.ltx_original_runtime.start();self._wait(s.ltx_original_runtime.status)
|
||||
if r.get('separator',{}).get('installed'):
|
||||
if not s.separator_runtime.status()['installed']:
|
||||
self._phase('Audio Separator installieren');s.separator_runtime.start();self._wait(s.separator_runtime.status)
|
||||
from separator_runtime import verify_package
|
||||
for name in r['separator']['models']:
|
||||
if not s.separator_runtime.model_ready(name) or not verify_package(s.separator_runtime.root/'models',name):
|
||||
self._phase('Audio-Separator-Modellpaket laden');s.separator_runtime.download(name);self._wait(s.separator_runtime.status)
|
||||
targets=r['llama_builds']+([r['llama']] if r['llama'] and r['llama'] not in r['llama_builds'] else [])
|
||||
for target in targets:
|
||||
state=s.runtime.status();matching=next((b for b in state['builds'] if b['commit']==target['commit'] and b['backend']==target['backend']),None)
|
||||
@@ -360,5 +389,10 @@ class Backup:
|
||||
with self.lock:
|
||||
self.cancel.set()
|
||||
if self.busy():
|
||||
self.server.catalog.stop();self.server.runtime.stop();self.server.image_runtime.stop();self.server.tts_runtime.stop();self.server.ltx_original_runtime.stop();self.server.prompt_enhancer.stop()
|
||||
self.server.catalog.stop();self.server.runtime.stop();self.server.image_runtime.stop();self.server.tts_runtime.stop();self.server.ltx_original_runtime.stop();
|
||||
if getattr(self.server,'separator_runtime',None):self.server.separator_runtime.stop()
|
||||
if getattr(self.server,'separator_tests',None):self.server.separator_tests.stop()
|
||||
if getattr(self.server,'music',None):self.server.music.stop()
|
||||
if getattr(self.server,'audio_cpp_runtime',None):self.server.audio_cpp_runtime.stop()
|
||||
self.server.prompt_enhancer.stop()
|
||||
return {'cancellation_requested':True}
|
||||
|
||||
+20
-6
@@ -1,5 +1,6 @@
|
||||
/* Original Deck UI, inspired by LocalAI's explore/detail/download workflow. */
|
||||
window.CatalogUI = (() => {
|
||||
let cachedCatalog = null; // Memory only: refreshed on every visit; never stores access tokens.
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const size = n => { if (!Number.isFinite(n)) return 'Unbekannt'; if(n < 1024) return n+' B'; const unit = n >= 1024**3 ? 'GiB' : n >= 1024**2 ? 'MiB' : 'KiB'; return (n / (unit === 'GiB' ? 1024**3 : unit === 'MiB' ? 1024**2 : 1024)).toFixed(1)+' '+unit; };
|
||||
const number = n => Number.isFinite(n) ? new Intl.NumberFormat('de-DE', {notation:'compact'}).format(n) : '–';
|
||||
@@ -9,12 +10,12 @@ window.CatalogUI = (() => {
|
||||
}
|
||||
function executionHTML(x) {
|
||||
if(!x)return '<p>Ausführbarkeit noch nicht geprüft.</p>';
|
||||
return `<section class="note execution-setup"><strong>${escape(x.label)}</strong><p>Modellfamilie: ${escape(x.family||'Unbekannt')}${x.evidence?' · '+escape(x.evidence):''}</p>${x.runtime?`<p>${escape(x.runtime)} · ${x.installed?'bereits installiert – wird wiederverwendet':'noch nicht eingerichtet'}</p>`:''}<p>${escape(x.message)}</p>${x.components?.length?`<p>Benötigt: ${x.components.map(escape).join(' · ')}. Bei Profilen unter „Komponenten“ zuordnen; TTS enthält das komplette Paket.</p>`:''}${x.route?`<a class="link" href="${escape(x.route)}">${x.installed?'Einrichtung ansehen':'Ausführung einrichten'} →</a>`:''}${x.alternative?`<p><a class="link" href="${escape(x.alternative.route)}">${escape(x.alternative.label)} →</a></p>`:''}<p class="small">Installierte Laufzeit bedeutet nicht, dass Modell und Kontext in den Speicher passen. Ein Test startet nur auf deinen Auftrag.</p></section>`;
|
||||
return `<section class="note execution-setup"><strong>${escape(x.label)}</strong><p>Modellfamilie: ${escape(x.family||'Unbekannt')}${x.evidence?' · '+escape(x.evidence):''}</p>${x.runtime?`<p>${escape(x.runtime)} · ${x.installed?'bereits installiert – wird wiederverwendet':'noch nicht eingerichtet'}</p>`:''}<p>${escape(x.message)}</p>${x.components?.length?`<p>Benötigt: ${x.components.map(escape).join(' · ')}. Modellabhängig zuordnen; benötigte Zusatzdateien gehören zum vollständigen Paket.</p>`:''}${x.suggested_components?.length?`<p>Zusatzdateien in dieser Quelle (Kompatibilität ungeprüft): ${x.suggested_components.map(escape).join(' · ')}. Nach dem Download unter Profil → Einrichtung & Komponenten prüfen.</p>`:''}${x.route?`<a class="link" href="${escape(x.route)}">${x.installed?'Einrichtung ansehen':'Ausführung einrichten'} →</a>`:''}${x.candidates?.length?`<h4>Mögliche Laufzeiten</h4><p class="small">Diese Liste unterscheidet bekannte Zuordnungen von ungeprüften Kategorieoptionen. Eine Installation ersetzt keinen fehlenden Worker.</p>${x.candidates.map(c=>`<p><strong>${escape(c.label)}</strong> · ${c.installed?'bereits installiert – wiederverwenden':'nicht installiert'}<br>${escape(c.evidence)}${c.route?`<br><a class="link" href="${escape(c.route)}">${c.installed?'Laufzeit / Einrichtung ansehen':'Laufzeit herunterladen / bereitmachen'} →</a>`:'<br>Kein gepflegter Deck-Installer für diese Option verfügbar.'}</p>`).join('')}`:`<p>${escape(x.candidate_message||'')}</p>`}${x.alternative?`<p><a class="link" href="${escape(x.alternative.route)}">${escape(x.alternative.label)} →</a></p>`:''}<p class="small">Installierte Laufzeit bedeutet nicht, dass Modell und Kontext in den Speicher passen. Ein Test startet nur auf deinen Auftrag.</p></section>`;
|
||||
}
|
||||
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>
|
||||
<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, Anbieter oder Repository-Link<input name="q" maxlength="120" placeholder="Name, GGUF-Dateiname oder Hugging-Face-Link" autocomplete="off"></label><button>Suchen</button></form><label class="hub-base-filter"><input id="hub-base-only" type="checkbox"> Nur Basismodelle</label><p class="small muted">Blendet anhand der Hub-Metadaten Finetunes, LoRAs, Merges und separate Quantisierungen aus. Unvollständig gekennzeichnete Ableitungen können sichtbar bleiben.</p><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>`;
|
||||
${downloads ? '' : library ? '<div id="hub-results" class="detail-list"></div>' : `<form id="hub-search" class="hub-search"><label>Modell, Anbieter oder Repository-Link<input name="q" maxlength="120" placeholder="Name, GGUF-Dateiname oder Hugging-Face-Link" autocomplete="off"></label><button>Suchen</button></form><label class="hub-base-filter"><input id="hub-base-only" type="checkbox"> Nur Basismodelle</label><p class="small muted">Blendet anhand der Hub-Metadaten Finetunes, LoRAs, Merges und separate Quantisierungen aus. Unvollständig gekennzeichnete Ableitungen können sichtbar bleiben.</p><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) {
|
||||
const root = document.querySelector('#live-catalog'); if (!root) return;
|
||||
@@ -30,7 +31,13 @@ window.CatalogUI = (() => {
|
||||
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
|
||||
}
|
||||
function renderLibrary() {
|
||||
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${executionHTML(x.execution)}${x.used_by_profiles?.length?`<p class="warning">Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.</p>`:""}<div class="card-actions">${x.profile_eligible===true?`<button data-profile-model="${x.id}">${kind==='video'?'Als Videomodell verwenden':'Profil anlegen'}</button>`:""}<button type="button" class="secondary" data-delete-model="${x.id}" ${x.used_by_profiles?.length?"disabled":""}>Datei löschen</button></div></article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
|
||||
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId));
|
||||
const entries = state.entries.filter(x => x.kind === kind);
|
||||
const groups = [['model', kind==='chat'?'Sprachmodelle · Hauptgewichte':'Modelle · Hauptgewichte'],['vision_projector','Vision-Projektoren'],['audio_projector','Audio-Projektoren'],['text_encoder','Textencoder'],['vae','VAE · Decoder'],['configuration','Konfigurationen'],['auxiliary','Tokenizer und weitere Zusatzkomponenten']];
|
||||
const row = x => `<details class="detail-row" data-row-id="${escape(x.id)}" ${openRows.has(x.id)?'open':''}><summary class="detail-summary"><strong class="hub-filename">${escape(x.file)}</strong><span>${escape(x.role_label)}</span><span>${size(x.size)}</span><span>${x.used_by_profiles?.length?`${x.used_by_profiles.length} Profil(e)`:'Nicht zugeordnet'}</span></summary><div class="detail-body"><p class="hub-filename">Quelle: ${escape(x.download_source?.repo||x.repo)}</p>${x.download_source?`<p class="small">Lokal übernommen · Originaldatei: ${escape(x.download_source.file)} · Quellrevision: ${escape(x.download_source.revision)}</p>`:""}<p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${executionHTML(x.execution)}${x.used_by_profiles?.length?`<p class="warning">Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.</p>`:""}<div class="card-actions">${x.profile_eligible===true?`<button data-profile-model="${x.id}">${kind==='video'?'Als Videomodell verwenden':'Profil anlegen'}</button>`:""}<button type="button" class="secondary" data-delete-model="${x.id}" ${x.used_by_profiles?.length?"disabled":""}>Datei löschen</button></div></div></details>`;
|
||||
const known = new Set(groups.map(([role])=>role));
|
||||
const sections = [...groups, ['other','Weitere Dateien · Zuordnung unbekannt']];
|
||||
el('hub-results').innerHTML = sections.map(([role,label])=>{const files=entries.filter(x=>role==='other'?!known.has(x.role):x.role===role);return files.length?`<section class="library-group" aria-label="${escape(label)}"><h3>${escape(label)} <span class="small muted">(${files.length})</span></h3><div class="detail-list">${files.map(row).join('')}</div></section>`:'';}).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
|
||||
root.querySelectorAll('[data-delete-model]').forEach(b=>b.onclick=async()=>{const entry=state.entries.find(x=>x.id===b.dataset.deleteModel);if(!entry||!confirm(`Datei „${entry.file}“ (${size(entry.size)}) dauerhaft von Athena löschen?`))return;b.disabled=true;try{await api('/delete',{id:entry.id});message('Datei aus der Bibliothek gelöscht.');await refresh();}catch(error){message(error.message);b.disabled=false;}});
|
||||
}
|
||||
function renderJob() {
|
||||
@@ -38,14 +45,14 @@ window.CatalogUI = (() => {
|
||||
const jobs=downloads ? (state.downloads||[]).filter(j=>j.kind===kind) : state.job?.state==='downloading' ? [state.job] : [];
|
||||
const signature=JSON.stringify(jobs); if(signature===lastJobs)return; lastJobs=signature;
|
||||
const labels={queued:'Warteschlange',downloading:'Wird heruntergeladen',complete:'Download abgeschlossen',cancelled:'Download abgebrochen',failed:'Download fehlgeschlagen',interrupted:'Download unterbrochen'};
|
||||
el('hub-job').innerHTML=jobs.map(j=>{const percent=j.total>0?Math.min(100,Math.max(0,j.bytes/j.total*100)):0;return `<section class="card hub-operation"><div class="card-top"><span class="kicker">${escape(labels[j.state]||j.state)}</span><strong>${percent.toFixed(1)} %</strong></div><h3>${escape(j.repo)}</h3><p class="hub-filename">${escape(j.file)}</p><progress max="100" value="${percent}" aria-label="Download-Fortschritt"></progress><p>${size(j.bytes)} von ${size(j.total)}${j.state==='downloading'?` · ${Number.isFinite(j.bytes_per_second)?size(j.bytes_per_second)+'/s':'Geschwindigkeit wird ermittelt …'} · Restzeit: ${Number.isFinite(j.eta_seconds)?Math.ceil(j.eta_seconds/60)+' Min.':'–'}`:''}</p>${j.error?`<p class="warning">${escape(j.error)}</p>`:''}${j.state==='downloading'?'<button class="secondary" data-cancel>Download abbrechen</button>':`<button class="secondary" data-dismiss="${escape(j.id)}">${j.state==='queued'?'Aus Warteschlange entfernen':'Aus Liste entfernen'}</button><span class="small muted">${j.state==='queued'?'Download noch nicht gestartet':'Datei bleibt erhalten'}</span>`}</section>`;}).join('')||(downloads?'<div class="empty"><h3>Keine Downloads in dieser Liste</h3><p>Heruntergeladene Modelldateien findest du weiterhin in der Bibliothek.</p></div>':'');
|
||||
el('hub-job').innerHTML=jobs.map(j=>{const percent=j.total>0?Math.min(100,Math.max(0,j.bytes/j.total*100)):0;return `<section class="card hub-operation download-row"><div class="card-top"><span class="kicker">${escape(labels[j.state]||j.state)}</span><strong>${percent.toFixed(1)} %</strong></div><h3>${escape(j.repo)}</h3><p class="hub-filename">${escape(j.file)}</p><progress max="100" value="${percent}" aria-label="Download-Fortschritt"></progress><p>${size(j.bytes)} von ${size(j.total)}${j.state==='downloading'?` · ${Number.isFinite(j.bytes_per_second)?size(j.bytes_per_second)+'/s':'Geschwindigkeit wird ermittelt …'} · Restzeit: ${Number.isFinite(j.eta_seconds)?Math.ceil(j.eta_seconds/60)+' Min.':'–'}`:''}</p>${j.error?`<p class="warning">${escape(j.error)}</p>`:''}${j.state==='downloading'?'<button class="secondary" data-cancel>Download abbrechen</button>':`<button class="secondary" data-dismiss="${escape(j.id)}">${j.state==='queued'?'Aus Warteschlange entfernen':'Aus Liste entfernen'}</button><span class="small muted">${j.state==='queued'?'Download noch nicht gestartet':'Datei bleibt erhalten'}</span>`}</section>`;}).join('')||(downloads?'<div class="empty"><h3>Keine Downloads in dieser Liste</h3><p>Heruntergeladene Modelldateien findest du weiterhin in der Bibliothek.</p></div>':'');
|
||||
root.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/cancel',{});message('Abbruch angefordert; die Netzwerkabfrage kann bis zu 20 Sekunden dauern.');}catch(error){message(error.message);b.disabled=false;}});
|
||||
root.querySelectorAll('[data-dismiss]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/dismiss',{id:b.dataset.dismiss});message('Eintrag ausgeblendet. Die Modelldatei bleibt in der Bibliothek.');await refresh();}catch(error){message(error.message);b.disabled=false;}});
|
||||
}
|
||||
async function refresh() {
|
||||
if (refreshing || !root.isConnected) return; refreshing = true; clearTimeout(timer);
|
||||
try { const next = await api(''); if (!root.isConnected) return; const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(state.entries); const statusChanged = JSON.stringify(next) !== JSON.stringify(state); state = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>kind==='video'?fetch('/api/v1/video/service',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({id:b.dataset.profileModel})}).then(async r=>{const data=await r.json();if(!r.ok)throw Error(data.error);location.hash='video';VideoUI.runtime();}).catch(x=>alert(x.message)):Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } }
|
||||
catch(error) { message(error.message); }
|
||||
try { const next = await api(''); if (!root.isConnected) return; const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(state.entries); const statusChanged = JSON.stringify(next) !== JSON.stringify(state); state = next; cachedCatalog = next; if (statusChanged) renderJob(); if (library) { if (entriesChanged || !libraryRendered) { renderLibrary(); libraryRendered = true; message(''); root.querySelectorAll('[data-profile-model]').forEach(b=>b.onclick=()=>kind==='video'?fetch('/api/v1/video/service',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({id:b.dataset.profileModel})}).then(async r=>{const data=await r.json();if(!r.ok)throw Error(data.error);location.hash='video';VideoUI.runtime();}).catch(x=>alert(x.message)):Studio.openProfile(kind,b.dataset.profileModel)); } } else if (!downloads) { if (entriesChanged) renderModels(); updateSelection(); } }
|
||||
catch(error) { message(library && cachedCatalog ? 'Zuletzt geladene Dateien · Aktualisierung fehlgeschlagen: '+error.message : error.message); }
|
||||
finally { refreshing = false; if (root.isConnected) timer = setTimeout(refresh, 2500); }
|
||||
}
|
||||
async function search(q) {
|
||||
@@ -98,6 +105,13 @@ window.CatalogUI = (() => {
|
||||
}
|
||||
el('hub-refresh').onclick = refresh;
|
||||
if (!library && !downloads) { try { el('hub-base-only').checked=localStorage.getItem('deck-base-only')==='true'; } catch {} el('hub-base-only').onchange=()=>{try { localStorage.setItem('deck-base-only',String(el('hub-base-only').checked)); } catch {} search(new FormData(el('hub-search')).get('q').trim());}; 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(''); }
|
||||
if (library) {
|
||||
if (cachedCatalog) {
|
||||
state = cachedCatalog; renderLibrary();
|
||||
root.querySelectorAll('[data-delete-model], [data-profile-model]').forEach(b=>b.disabled=true);
|
||||
message('Zuletzt geladene Dateien · wird aktualisiert …');
|
||||
} else message('Bibliothek wird geladen …');
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
return {html,bind,executionHTML};
|
||||
|
||||
+11
-3
@@ -89,9 +89,13 @@ def runtime_metadata(model):
|
||||
architecture=next((x for x in architectures if isinstance(x,str) and 0<len(x)<=100),None)
|
||||
architecture=architecture or next((config.get(k) for k in ('_class_name','model_type') if isinstance(config.get(k),str) and 0<len(config[k])<=100),None)
|
||||
tags=[x for x in model.get('tags',[]) if isinstance(x,str) and len(x)<=80][:32] if isinstance(model.get('tags'),list) else []
|
||||
card=model.get('cardData') if isinstance(model.get('cardData'),dict) else {}
|
||||
base=card.get('base_model',[])
|
||||
base=[base] if isinstance(base,str) else base if isinstance(base,list) else []
|
||||
base_models=[x for x in base if isinstance(x,str) and re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+',x)][:8]
|
||||
return dict(pipeline_tag=model.get('pipeline_tag') if isinstance(model.get('pipeline_tag'),str) and len(model['pipeline_tag'])<=80 else None,
|
||||
library_name=model.get('library_name') if isinstance(model.get('library_name'),str) and len(model['library_name'])<=80 else None,
|
||||
architecture=architecture,tags=tags)
|
||||
architecture=architecture,tags=tags,base_models=base_models)
|
||||
|
||||
class Catalog:
|
||||
def __init__(self,root):
|
||||
@@ -197,7 +201,7 @@ class Catalog:
|
||||
files=[]
|
||||
for f in data.get('siblings',[]):
|
||||
name=f['rfilename'];p=PurePosixPath(name)
|
||||
if p.is_absolute() or '..' in p.parts or p.suffix.lower() not in ('.gguf','.safetensors','.json'):continue
|
||||
if p.is_absolute() or '..' in p.parts or p.suffix.lower() not in ('.gguf','.safetensors','.json','.tiktoken'):continue
|
||||
size=f.get('size',f.get('lfs',{}).get('size'))
|
||||
if not isinstance(size,int) or size<1:continue
|
||||
files.append(dict(name=name,size=size,sha256=f.get('lfs',{}).get('sha256')))
|
||||
@@ -212,7 +216,11 @@ class Catalog:
|
||||
if self.root.exists():
|
||||
for p in self.root.glob('*/entry.json'):
|
||||
try:
|
||||
item=json.loads(p.read_text());item['id']=p.parent.name;item.update(file_role(item['file']));entries.append(item)
|
||||
item=json.loads(p.read_text());item['id']=p.parent.name;item.update(file_role(item['file']))
|
||||
if item['repo'].startswith('local/'):
|
||||
from backup import LOCAL_SOURCES
|
||||
item['download_source']=item.get('download_source') or LOCAL_SOURCES.get(item.get('sha256'))
|
||||
entries.append(item)
|
||||
except (OSError,ValueError):pass
|
||||
return dict(entries=entries,downloads=[dict(x) for x in reversed(self.history) if not x.get("dismissed")],job=dict(self.job) if self.job else None,free_bytes=shutil.disk_usage(self.root if self.root.exists() else self.root.parent).free)
|
||||
def start(self,repo,filename,revision,kind):
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ function applyDashboardStyles(container=root){
|
||||
}
|
||||
const imagePhaseLabel=p=>({'running':'Bildauftrag läuft','loading':'Bildmodell lädt','complete':'bereit','failed':'Fehler'})[p]||p||'inaktiv';
|
||||
function cleanup(){for(const timer of timers)clearInterval(timer);timers=[];controller?.abort();controller=null;host=null;root=null}
|
||||
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total&&g.memory_used_mib!=null?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0)),memoryText=total&&g.memory_used_mib!=null?`${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB`:'–';return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${esc(g.index)}</div><div class="value">${esc(g.name)}</div></div><span class="badge">${esc(g.pstate)}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${memoryText}</b><span>VRAM</span></div><div class="metric"><b>${esc(g.temperature_c)} °C</b><span>Temperatur</span></div><div class="metric"><b>${esc(g.power_w)} / ${esc(g.power_limit_w)} W</b><span>Leistung</span></div><div class="metric"><b>${esc(g.memory_controller_percent)} %</b><span>Speichercontroller</span></div><div class="metric"><b>${esc(g.graphics_clock_mhz)} MHz</b><span>GPU-Takt</span></div><div class="metric"><b>${esc(g.memory_clock_mhz)} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${esc(g.fan_percent)} %</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${pct(load)}</span></div><div class="bar"><div class="fill gpu-load" data-bar-percent="${load}"></div></div><div class="bar-label"><span>VRAM</span><span>${pct(p)}</span></div><div class="bar"><div class="fill" data-bar-percent="${p}"></div></div></article>`}
|
||||
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total&&g.memory_used_mib!=null?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0)),memoryText=total&&g.memory_used_mib!=null?`${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB`:'–';return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${esc(g.index)}</div><div class="value">${esc(g.name)}</div></div><span class="badge">${esc(g.pstate)}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${memoryText}</b><span>VRAM</span></div><div class="metric"><b>${esc(g.temperature_c)} °C</b><span>Temperatur</span></div><div class="metric"><b>${esc(g.power_w)} / ${esc(g.power_limit_w)} W</b><span>Leistung</span></div><div class="metric"><b>${esc(g.memory_controller_percent)} %</b><span>Speichercontroller</span></div><div class="metric"><b>${esc(g.graphics_clock_mhz)} MHz</b><span>GPU-Takt</span></div><div class="metric"><b>${esc(g.memory_clock_mhz)} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${esc(g.fan_percent)} %</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${pct(load)}</span></div><div class="bar"><div class="fill gpu-load" data-bar-percent="${load}"></div></div><div class="bar-label"><span>VRAM-Belegung</span><span>${pct(p)}</span></div><div class="bar"><div class="fill" data-bar-percent="${p}"></div></div><div class="sub">${g.memory_free_mib==null?'Freier VRAM nicht verfügbar':`${(g.memory_free_mib/1024).toFixed(1)} GiB VRAM frei`}</div></article>`}
|
||||
function mount(){
|
||||
historyColors=document.documentElement.dataset.theme==='athena-classic'?['#45d7ff','#ffc65c','#66e3a4','#bda7ff']:['#c5edaf','#ffb454','#7fc8b6','#c39bff'];
|
||||
controller=new AbortController();
|
||||
|
||||
+3
-3
@@ -11,13 +11,13 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
|
||||
&& /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt accelerate==1.15.0 \
|
||||
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography ffmpeg libsndfile1 && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
|
||||
COPY deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock
|
||||
COPY ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
|
||||
COPY ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
|
||||
COPY separator_sources.json separator.py separator_runtime.py separator_worker.py music.py audio_cpp_runtime.py ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
|
||||
COPY separator-ui.js music-ui.js audio-cpp-ui.js ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
|
||||
COPY deploy/docker_backup.py /app/deploy/docker_backup.py
|
||||
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
ARG DECK_RUNTIME_IMAGE
|
||||
FROM ${DECK_RUNTIME_IMAGE}
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography ffmpeg libsndfile1 && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
|
||||
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
|
||||
COPY deploy/ltx-original-requirements.lock /app/deploy/ltx-original-requirements.lock
|
||||
COPY ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
|
||||
COPY ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
|
||||
COPY separator_sources.json separator.py separator_runtime.py separator_worker.py music.py audio_cpp_runtime.py ltx_original_runtime.py ltx_original_entry.py video_original.py backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
|
||||
COPY separator-ui.js music-ui.js audio-cpp-ui.js ltx-original-ui.js backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
|
||||
COPY deploy/docker_backup.py /app/deploy/docker_backup.py
|
||||
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||
|
||||
+15
-7
@@ -20,6 +20,8 @@ def config_files(source,destination):
|
||||
p=Path(source)
|
||||
if destination.startswith('/run/secrets/'):
|
||||
return {'file':blob(p)} if p.exists() else {}
|
||||
if destination=='/data' and p.is_dir():
|
||||
return {name:blob(p/name) for name in ('settings.json','deck-connection.json') if (p/name).is_file()}
|
||||
if destination.endswith('/Data') and p.is_dir():
|
||||
files={}
|
||||
for name in ('Settings.fds','Backends.fds'):
|
||||
@@ -43,7 +45,7 @@ def export(manager):
|
||||
for row in manager.inventory():
|
||||
x=json.loads(command(['inspect',row['id']]))[0];c=x['Config'];h=x['HostConfig'];image=json.loads(command(['image','inspect',x['Image']]))[0]
|
||||
registry=next((d for d in image.get('RepoDigests',[]) if '/' in d.split('@')[0] and ('.' in d.split('/')[0] or ':' in d.split('/')[0])),None)
|
||||
if not registry and row['name']!='athena-swarm-ui':registry=next(iter(image.get('RepoDigests',[])),None)
|
||||
if not registry and row['name'] not in ('athena-swarm-ui','athena-ladypoly'):registry=next(iter(image.get('RepoDigests',[])),None)
|
||||
mounts=[]
|
||||
for i,m in enumerate(x['Mounts']):
|
||||
source=m['Source'];relative=None
|
||||
@@ -51,7 +53,7 @@ def export(manager):
|
||||
try:relative=str(Path(source).relative_to(deck))
|
||||
except ValueError:pass
|
||||
mounts.append(dict(index=i,target=m['Destination'],read_only=not m['RW'],deck_path=relative,files=config_files(source,m['Destination']),was_file=Path(source).is_file()))
|
||||
result.append(dict(name=row['name'],image=c['Image'],registry=registry,application=c.get('Labels',{}).get(APP,''),labels={k:v for k,v in (c.get('Labels') or {}).items() if k.startswith('io.athena-deck.')},env=c.get('Env') or [],entrypoint=c.get('Entrypoint'),cmd=c.get('Cmd'),uid=app_uid(row['id'],c.get('User') or ''),user=c.get('User') or '',workdir=c.get('WorkingDir') or '',network=h['NetworkMode'],ports=h.get('PortBindings') or {},restart=h.get('RestartPolicy',{}).get('Name') or 'no',memory=h.get('Memory') or 0,nanocpus=h.get('NanoCpus') or 0,running=x['State']['Running'],mounts=mounts,build_recipe='swarm-ui' if row['name']=='athena-swarm-ui' else None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd'))))
|
||||
result.append(dict(name=row['name'],image=c['Image'],registry=registry,application=c.get('Labels',{}).get(APP,''),labels={k:v for k,v in (c.get('Labels') or {}).items() if k.startswith('io.athena-deck.')},env=c.get('Env') or [],entrypoint=c.get('Entrypoint'),cmd=c.get('Cmd'),uid=app_uid(row['id'],c.get('User') or ''),user=c.get('User') or '',workdir=c.get('WorkingDir') or '',network=h['NetworkMode'],ports=h.get('PortBindings') or {},restart=h.get('RestartPolicy',{}).get('Name') or 'no',memory=h.get('Memory') or 0,nanocpus=h.get('NanoCpus') or 0,running=x['State']['Running'],mounts=mounts,build_recipe='swarm-ui' if row['name']=='athena-swarm-ui' else 'ladypoly' if row['name']=='athena-ladypoly' else 'separator-web' if row['name']=='athena-separator-web' else None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd'))))
|
||||
return {'services':result,'configured':bool(deck)}
|
||||
|
||||
def validate(c):
|
||||
@@ -59,8 +61,9 @@ def validate(c):
|
||||
if any(c.get('labels',{}).get(k)!=v for k,v in LABELS.items()):raise ValueError('Nur ausdrücklich Deck zugeordnete Anwendungscontainer erlaubt.')
|
||||
if c.get('unsupported'):raise ValueError('GPU-/privilegierter Container benötigt manuelle Einrichtung.')
|
||||
if c.get('network') not in ('bridge','default','host'):raise ValueError('Benutzerdefiniertes Docker-Netzwerk benötigt manuelle Einrichtung.')
|
||||
if c.get('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.')
|
||||
if c.get('build_recipe') not in (None,'swarm-ui') or c.get('build_recipe')=='swarm-ui' and (c['name']!='athena-swarm-ui' or c.get('image')!='athena-swarm-ui:de7b834'):raise ValueError('Unbekanntes Build-Rezept.')
|
||||
if c.get('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb','athena-ladypoly','athena-separator-web'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.')
|
||||
recipes={'separator-web':('athena-separator-web','athena-separator-web:0.1.0'),'swarm-ui':('athena-swarm-ui','athena-swarm-ui:de7b834'),'ladypoly':('athena-ladypoly','athena-ladypoly:3960afc-deck1')}
|
||||
if c.get('build_recipe') is not None and recipes.get(c['build_recipe'])!=(c['name'],c.get('image')):raise ValueError('Unbekanntes Build-Rezept.')
|
||||
ref=c.get('registry') or c.get('image','')
|
||||
if not isinstance(ref,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/@:-]{0,300}',ref):raise ValueError('Ungültige Image-Referenz.')
|
||||
if c.get('restart') not in ('no','always','unless-stopped','on-failure'):raise ValueError('Ungültige Neustartregel.')
|
||||
@@ -93,7 +96,9 @@ def validate(c):
|
||||
native_media=rel in media and c['name']=='ltx-deskweb' and (target,m['read_only'])==media[rel] and not m['was_file']
|
||||
if rel is not None and not native_media and (rel not in ('models','video/comfy-work/models','video/comfy-client-token') or not m['read_only']):raise ValueError('Deck-Volume nicht freigegeben; nur bekannte Modell-/Tokenpfade und LTX-Medienordner erlaubt.')
|
||||
for name,data in m.get('files',{}).items():
|
||||
if name not in ('file','Settings.fds','Backends.fds'):raise ValueError('Unbekannte Konfigurationsdatei.')
|
||||
allowed=('file','Settings.fds','Backends.fds')
|
||||
if c['name'] in ('athena-ladypoly','athena-separator-web') and m['target']=='/data':allowed+=('settings.json','deck-connection.json')
|
||||
if name not in allowed:raise ValueError('Unbekannte Konfigurationsdatei.')
|
||||
if not isinstance(data,str) or len(base64.b64decode(data,validate=True))>MAX_FILE:raise ValueError('Ungültige Konfigurationsdatei.')
|
||||
return c
|
||||
|
||||
@@ -110,8 +115,10 @@ def restore(manager,c):
|
||||
# Never replace or restart an existing service during a restore.
|
||||
if x['Config']['Image'] not in (c['image'],c.get('registry')):raise ValueError('Vorhandener Container verwendet ein anderes Image; manuell prüfen.')
|
||||
return {'state':'reused','message':'Vorhandener markierter Container unverändert übernommen.'}
|
||||
if c.get('build_recipe')=='swarm-ui':
|
||||
source=Path(__file__).parent/'swarm-ui'
|
||||
if c['name'] in ('athena-ladypoly','athena-separator-web'):
|
||||
with socket.socket() as probe:probe.bind(('127.0.0.1',8127 if c['name']=='athena-ladypoly' else 8128))
|
||||
if c.get('build_recipe') in ('swarm-ui','ladypoly','separator-web'):
|
||||
source=Path(__file__).parent/c['build_recipe']
|
||||
if not (source/'Dockerfile').is_file():raise ValueError('Gepinntes Swarm-Build-Rezept fehlt im Systemhelfer.')
|
||||
command(['build','-t',c['image'],str(source)],1800)
|
||||
else:
|
||||
@@ -135,6 +142,7 @@ def restore(manager,c):
|
||||
if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.')
|
||||
base.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
args=['create','--name',name,'--network',c['network'],'--restart',c['restart'],'--cap-drop','ALL','--security-opt','no-new-privileges:true','--pids-limit','256']
|
||||
if c['name']=='athena-ladypoly':args+=['--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size=128m,uid=65534,gid=65534']
|
||||
if c['memory']:args+=['--memory',str(c['memory'])]
|
||||
if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)]
|
||||
for k,v in c['labels'].items():
|
||||
|
||||
@@ -141,8 +141,21 @@ class Manager:
|
||||
result=run(['/usr/bin/docker','start',config['container_id']] if start else ['/usr/bin/docker','stop','--time','30',config['container_id']],timeout=45)
|
||||
if result.returncode:raise ValueError('Videodienst konnte nicht gestartet oder beendet werden.')
|
||||
return self.video_status(config)
|
||||
def container_action(self,ident,action):
|
||||
if action not in ('start','stop','delete') or not isinstance(ident,str) or not re.fullmatch('[a-f0-9]{64}',ident):raise ValueError('Ungültige Containeraktion.')
|
||||
with self.lock:
|
||||
row=next((c for c in self.inventory() if c['id']==ident),None)
|
||||
if row is None:raise ValueError('Container nicht für Deck freigegeben oder nicht mehr vorhanden.')
|
||||
if any(c['container_id']==ident for c in self.video_configs()):raise ValueError('Registrierte Video-Laufzeiten über die Steuerung verwalten.')
|
||||
if action=='delete' and row['state'] not in ('exited','created','dead'):raise ValueError('Container vor dem Löschen stoppen.')
|
||||
args=['/usr/bin/docker']+(['stop','--time','30',ident] if action=='stop' else ['rm',ident] if action=='delete' else ['start',ident])
|
||||
result=run(args,timeout=45)
|
||||
if result.returncode:raise ValueError('Containeraktion fehlgeschlagen. Status erneut prüfen.')
|
||||
return dict(changed=True,action=action,id=ident)
|
||||
def dispatch(self,data):
|
||||
if not isinstance(data,dict):raise ValueError('Ungültige Helferanfrage.')
|
||||
if set(data)=={'action','service'} and data['action'] in ('container-start','container-stop','container-delete'):
|
||||
return self.container_action(data['service'],data['action'].removeprefix('container-'))
|
||||
if data.get('action')=='backup-export' and set(data)=={'action'}:
|
||||
import docker_backup
|
||||
return docker_backup.export(self)
|
||||
|
||||
+6
-3
@@ -14,7 +14,7 @@ import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||
FILES = ['ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','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','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.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 = ['separator_sources.json','deploy/separator-web/Dockerfile','deploy/separator-web/app.py','deploy/separator-web/index.html','separator.py','separator_runtime.py','separator_worker.py','separator-ui.js','deploy/ladypoly/Dockerfile','deploy/ladypoly/bridge.py','music.py','music-ui.js','audio_cpp_runtime.py','audio-cpp-ui.js','ltx_original_runtime.py','ltx_original_entry.py','video_original.py','ltx-original-ui.js','deploy/ltx-original-requirements.lock','backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','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','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.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):
|
||||
@@ -95,7 +95,7 @@ def launch(config):
|
||||
base=Path(config['base'])
|
||||
args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base),
|
||||
'--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true',
|
||||
'--pids-limit','256' if config.get('image_runtime') else '128','--memory','32g' if config.get('image_runtime') else '8g','--cpus','2', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m',
|
||||
'--pids-limit','256' if config.get('image_runtime') else '128','--memory','32g' if config.get('image_runtime') else '8g','--cpus','6', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m',
|
||||
'-v',str(base/'state')+':/var/lib/deck:rw','--mount','type=bind,src=/proc,dst=/host/proc,readonly','-e','DECK_HOST_PROC=/host/proc','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']),
|
||||
'-p','127.0.0.1:'+str(config['port'])+':8108',
|
||||
'--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"',
|
||||
@@ -204,7 +204,7 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals
|
||||
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('ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*'))
|
||||
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('music-verification','music-test-temp','music-jobs','separator-runtime','separator-tests','audio-cpp-runtime','ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*'))
|
||||
was_running=previous['State']['Running']
|
||||
if was_running:run('docker','stop','--time','15',config['name'])
|
||||
run('docker','rename',config['name'],backup_name)
|
||||
@@ -218,6 +218,9 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals
|
||||
if was_running:run('docker','start',config['name'])
|
||||
raise RuntimeError('Update fehlgeschlagen. Vorheriger Deck-Container wiederhergestellt.') from None
|
||||
run('docker','rm',backup_name)
|
||||
# Keep only two automatic pre-update state snapshots after a successful update.
|
||||
snapshots=sorted((p for p in (base/'backups').iterdir() if p.is_dir() and not p.is_symlink() and p.name.isdigit()),key=lambda p:int(p.name))
|
||||
for snapshot in snapshots[:-2]:shutil.rmtree(snapshot)
|
||||
print('Nur Athena Deck wurde aktualisiert. Vorheriges Image bleibt für --rollback erhalten.')
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
ARG UPSTREAM_COMMIT=3960afc309efcef5f4f570209cf01160b96b1ea3
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git libsndfile1 && rm -rf /var/lib/apt/lists/* \
|
||||
&& git init /app && cd /app && git remote add origin https://github.com/Ladypoly/YuE2_WebUI.git \
|
||||
&& git fetch --depth 1 origin ${UPSTREAM_COMMIT} && git checkout --detach FETCH_HEAD \
|
||||
&& rm -rf /app/.git \
|
||||
&& pip install --no-cache-dir fastapi==0.115.12 uvicorn==0.34.2 python-multipart==0.0.20 numpy==2.2.6 soundfile==0.13.1
|
||||
COPY bridge.py /app/webui/deck_bridge.py
|
||||
RUN python -c "from pathlib import Path; p=Path('/app/webui/server.py'); s=p.read_text(); s=s.replace('SETTINGS_FILE = Path(__file__).resolve().parent / \"settings.json\"','SETTINGS_FILE = Path(\"/data/settings.json\")'); s=s.replace('from loras import Loras', 'from deck_bridge import Loras'); s=s.replace('if __name__ == \"__main__\":','from deck_bridge import install\ninstall(globals())\n\nif __name__ == \"__main__\":'); p.write_text(s)"
|
||||
RUN python -c "from pathlib import Path; p=Path('/app/webui/static/index.html'); s=p.read_text().replace('<body>', '<body><div style=\"padding:12px;background:#193249;color:white\">LadyPoly mit Athena Deck · <a style=\"color:#8de8ff\" href=\"/deck-connection\">Deck-Verbindung einrichten</a> · In Deck Musik aktivieren. Worker-Abbruch in Deck.</div>'); s=s.replace('value=\"full\" checked', 'value=\"full\" disabled').replace('value=\"melody\"', 'value=\"melody\" disabled').replace('value=\"off\"', 'value=\"off\" checked'); s=s.replace('</head>', '<style>#engineToggle,#muse,#coverDrawer,#scoreDrawer,#advDrawer,#cancelRun{display:none!important}</style></head>'); p.write_text(s)"
|
||||
ENV PYTHONPATH=/app/src YUE2_HOST=127.0.0.1 YUE2_PORT=8127 YUE2_OUTPUTS=/data/outputs HOME=/data
|
||||
WORKDIR /app
|
||||
USER 65534:65534
|
||||
CMD ["python", "webui/server.py"]
|
||||
@@ -0,0 +1,52 @@
|
||||
# LadyPoly YuE2 für Athena Deck
|
||||
|
||||
Die gepinnte LadyPoly-WebUI (Commit 3960afc309efcef5f4f570209cf01160b96b1ea3)
|
||||
läuft separat ohne Torch, CUDA, Modellmounts oder Docker-Socket. Die schmale
|
||||
Anbindung nutzt Decks authentifizierte Musik-API auf 127.0.0.1:8120.
|
||||
Deck steuert audio.cpp, Gewichte und den exklusiven Musikmodus.
|
||||
|
||||
Auf Debian mit vorhandenem Docker, im Athena-Deck-Repository:
|
||||
|
||||
```sh
|
||||
docker build -t athena-ladypoly:3960afc-deck1 deploy/ladypoly
|
||||
sudo python3 deploy/ladypoly/install.py
|
||||
```
|
||||
|
||||
Container: `athena-ladypoly`. Frei zu prüfender Port: **8127**, nur Host-Loopback.
|
||||
Host-Netzwerk verbindet zur lokalen Deck-API; keine öffentliche Portfreigabe.
|
||||
Labels: `io.athena-deck.managed=true`, `io.athena-deck.role=application`,
|
||||
`io.athena-deck.application=ladypoly`. Diese machen ihn unter Weitere Dienste sichtbar.
|
||||
Volume: `/opt/athena-ladypoly/data:/data:rw`, UID/GID 65534, Verzeichnis 0700.
|
||||
Enthält Verbindungsdaten/Token (0600), UI-Einstellungen und generierte Musik.
|
||||
Keine Modelle oder Runtime im Container. Image wird aus diesem Rezept gebaut;
|
||||
noch kein öffentliches Registry-Image/Pull-Befehl verfügbar.
|
||||
|
||||
```sh
|
||||
ssh -i ~/.ssh/athena_key -o BatchMode=yes -o ExitOnForwardFailure=yes -N -L 8127:127.0.0.1:8127 root@192.168.1.212
|
||||
```
|
||||
|
||||
Dann http://127.0.0.1:8127/deck-connection öffnen. Eigenen Deck-API-Token und
|
||||
exakten freigegebenen YuE2-Profilnamen eingeben. Die Verbindung wird gegen die
|
||||
Musik-Modellliste geprüft. Bei Tokenwechsel hier erneut speichern.
|
||||
In Deck auf Musik umschalten, anschließend in LadyPoly Stil und Liedtext
|
||||
angeben und generieren. Deck-Endpunkt muss gestartet sein.
|
||||
|
||||
Unterstützt: Titel/Bibliothek, Liedtext, Stil, Seed, WAV-Erzeugung über Deck,
|
||||
FLAC-Bibliothek und Wiedergabe. Derzeit Direct-Modus, bis 9000 semantische Tokens
|
||||
und sechs Syntheseschritte. Kein Versprechen einer bestimmten Songdauer.
|
||||
Writer, Cover-Erzeugung, Score, LoRA und Sampling-Overrides sind nicht angebunden;
|
||||
die betreffenden Bedienelemente sind ausgeblendet/gesperrt. Abbruch eines laufenden
|
||||
GPU-Jobs erfolgt in Deck; die WebUI ist kein zweiter Prozessmanager.
|
||||
|
||||
```sh
|
||||
docker stop athena-ladypoly
|
||||
docker start athena-ladypoly
|
||||
```
|
||||
|
||||
Der Deck-Backuphelfer kennt das gepinnte Build-Rezept und sichert die beiden
|
||||
Konfigurationsdateien im verschlüsselten Backup. Ergebnisse unter outputs sind
|
||||
Nutzdaten und werden nicht exportiert. Beim Restore wird das Image wieder gebaut;
|
||||
der Port muss frei sein. Deck-API-Portänderungen erfordern derzeit eine Anpassung
|
||||
von `BASE` in bridge.py und einen Image-Neubau.
|
||||
|
||||
Upstream-Lizenz und Hinweise verbleiben unter /app im Image.
|
||||
@@ -0,0 +1,73 @@
|
||||
"""LadyPoly UI -> authenticated Deck music API. No local model engine."""
|
||||
import json, os, time, urllib.request, urllib.error
|
||||
from pathlib import Path
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
CONNECTION = Path('/data/deck-connection.json')
|
||||
BASE = 'http://127.0.0.1:8120'
|
||||
|
||||
def request(path, data=None, connection=None):
|
||||
c = connection or (json.loads(CONNECTION.read_text()) if CONNECTION.exists() else {})
|
||||
if not c.get('token'): raise RuntimeError('Zuerst unter Deck-Verbindung den API-Token hinterlegen.')
|
||||
req = urllib.request.Request(BASE + path, data=json.dumps(data).encode() if data is not None else None,
|
||||
headers={'Authorization': 'Bearer ' + c['token'], 'Content-Type':'application/json'})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=1850) as response: return response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
try: message=json.loads(exc.read()).get('error', {}).get('message', 'Deck-Anfrage abgelehnt')
|
||||
except Exception: message='Deck-Anfrage abgelehnt'
|
||||
raise RuntimeError(message) from None
|
||||
|
||||
class Loras:
|
||||
def __init__(self, root):self.extra_dirs=[]
|
||||
def resolve(self, values):return [], []
|
||||
def status(self, values):return {'adapters':[], 'selected':[], 'missing':[], 'dirs':[]}
|
||||
def discover(self):return []
|
||||
|
||||
class Connection(BaseModel):
|
||||
token: str
|
||||
model: str
|
||||
|
||||
PAGE='''<!doctype html><meta charset="utf-8"><style>body{background:#12151c;color:#eee;font:18px system-ui;max-width:720px;margin:50px auto}input,button{padding:12px;display:block;margin:15px 0;width:90%}a{color:#72d6ff}</style><h1>LadyPoly · Deck-Verbindung</h1><p>In Deck das YuE2-Profil am API-Endpunkt freigeben und den Musik-Modus aktivieren. Hier den API-Token aus „Zugang & API“ und den genauen Profilnamen eingeben.</p><form id="f"><input id="token" type="password" placeholder="Deck API-Token" required autocomplete="off"><input id="model" placeholder="API-Profilname, z. B. YuE2 Studio" required><button>Verbindung prüfen und speichern</button></form><p id="msg"></p><a href="/">LadyPoly öffnen →</a><p>Diese Anbindung unterstützt Liedtext, Stil, Seed und Syntheseschritte. Score, LoRA, eigener Writer und Cover-Generator sind hier noch nicht angebunden. Deck lädt und entlädt die Gewichte.</p><script>f.onsubmit=async e=>{e.preventDefault();msg.textContent='Prüfe …';try{let r=await fetch('/api/deck/connection',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:token.value,model:model.value})});let d=await r.json();msg.textContent=r.ok?'Verbunden. Jetzt LadyPoly öffnen.':d.detail;token.value=''}catch(e){msg.textContent='Verbindung fehlgeschlagen'}}</script>'''
|
||||
|
||||
def install(g):
|
||||
app=g['app'];g['SETTINGS'].song_engine='gguf';g['SETTINGS'].art_auto=False;g['SETTINGS'].stall_timeout=0
|
||||
@app.get('/deck-connection', response_class=HTMLResponse)
|
||||
def connection_page():return PAGE
|
||||
@app.post('/api/deck/connection')
|
||||
def connection_save(body: Connection):
|
||||
c=body.model_dump()
|
||||
if not 1<=len(body.token)<=512:raise HTTPException(400,'Ungültiger Token.')
|
||||
try:
|
||||
models=json.loads(request('/v1/audio/music/models',connection=c))['data']
|
||||
if body.model not in [x['id'] for x in models]:raise RuntimeError('Profil nicht freigegeben oder nicht ausführbar. Bitte in Deck prüfen.')
|
||||
except Exception as e:raise HTTPException(400,str(e)) from None
|
||||
CONNECTION.write_text(json.dumps(c));CONNECTION.chmod(0o600)
|
||||
return {'ok':True}
|
||||
def render(job,spec):
|
||||
if spec.get('abc') or spec.get('cfg_scale') is not None or spec.get('abc_sampling') or spec.get('semantic_sampling') or g['SETTINGS'].loras:
|
||||
raise RuntimeError('Score, Sampling-Overrides und LoRA sind in der Deck-Anbindung noch nicht verfügbar.')
|
||||
c=json.loads(CONNECTION.read_text()) if CONNECTION.exists() else {}
|
||||
spec['cot']='off'
|
||||
job.setup='Deck lädt YuE2 und erzeugt Musik · Musik-Modus erforderlich';job.setup_at=time.time();job.push(force=True)
|
||||
start=time.monotonic()
|
||||
audio=request('/v1/audio/music',dict(model=c.get('model',''),lyrics=spec['lyrics'],style=spec['style'],seed=spec['seed'],steps=g['SETTINGS'].ode_steps,max_tokens=9000))
|
||||
if job.cancel_flag.is_set():raise InterruptedError('UI-Auftrag abgebrochen. Laufenden Deck-Job in Deck stoppen.')
|
||||
work=g['OUTPUTS']/('.pending-'+job.id);work.mkdir(parents=True,exist_ok=True)
|
||||
wav=work/'audio.wav';wav.write_bytes(audio)
|
||||
return g['GgufSong'](spec,wav,{'e2e_seconds':time.monotonic()-start},'',{'runtime':'Athena Deck audio.cpp','profile':c.get('model')})
|
||||
g['render_gguf']=render;g['render_torch']=render
|
||||
g['ENGINE'].detail='Remote-Laufzeit in Athena Deck · Musik-Modus erforderlich'
|
||||
@app.middleware('http')
|
||||
async def deck_guard(req,call_next):
|
||||
path=req.url.path
|
||||
origin=req.headers.get('Origin')
|
||||
if req.method!='GET' and origin and origin not in ('http://'+req.headers.get('Host',''),'https://'+req.headers.get('Host','')):
|
||||
return g['JSONResponse']({'detail':'Cross-Origin-Zugriff nicht erlaubt.'},status_code=403)
|
||||
if req.method!='GET' and not (path in {'/api/generate','/api/deck/connection','/api/unlock'} or (req.method=='DELETE' and path.startswith('/api/library/') and path.count('/')==3)):
|
||||
return g['JSONResponse']({'detail':'Laufzeiten, Modelle und Einstellungen werden in Athena Deck verwaltet.'},status_code=409)
|
||||
response=await call_next(req)
|
||||
response.headers['Cache-Control']='no-store'
|
||||
return response
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Run as root on Athena. Only creates the explicit optional LadyPoly UI."""
|
||||
import os, socket, subprocess
|
||||
from pathlib import Path
|
||||
base=Path('/opt/athena-ladypoly/data');name='athena-ladypoly';image='athena-ladypoly:3960afc-deck1'
|
||||
if subprocess.run(['docker','inspect',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL).returncode==0:
|
||||
raise SystemExit('LadyPoly container already exists; no automatic replacement.')
|
||||
with socket.socket() as s:
|
||||
s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
|
||||
s.bind(('127.0.0.1',8127))
|
||||
base.mkdir(parents=True,exist_ok=True);os.chown(base,65534,65534);base.chmod(0o700)
|
||||
subprocess.check_call(['docker','run','-d','--name',name,'--network','host','--restart','unless-stopped','--read-only',
|
||||
'--cap-drop','ALL','--security-opt','no-new-privileges:true','--memory','1g','--cpus','2',
|
||||
'--tmpfs','/tmp:rw,noexec,nosuid,size=128m,uid=65534,gid=65534',
|
||||
'--label','io.athena-deck.managed=true','--label','io.athena-deck.role=application',
|
||||
'--label','io.athena-deck.application=ladypoly','--label','io.athena-deck.port=8127',
|
||||
'-v',str(base)+':/data:rw',image])
|
||||
print('LadyPoly UI: http://127.0.0.1:8127/deck-connection')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Synthetic bridge contract; no real API token or GPU job."""
|
||||
import io,json,tempfile,unittest,wave
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import server
|
||||
import deck_bridge as bridge
|
||||
|
||||
class BridgeTest(unittest.TestCase):
|
||||
def test_forward_and_audio_library(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root=Path(temp);connection=root/'connection.json';connection.write_text(json.dumps({'model':'Synthetic Music','token':'synthetic'}))
|
||||
raw=io.BytesIO()
|
||||
with wave.open(raw,'wb') as wav:wav.setnchannels(2);wav.setsampwidth(2);wav.setframerate(48000);wav.writeframes(b'\0'*192000)
|
||||
job=SimpleNamespace(id='synthetic',cancel_flag=SimpleNamespace(is_set=lambda:False),push=lambda **k:None)
|
||||
spec={'style':'synthetic piano','lyrics':'synthetic test','seed':123,'id':'synthetic','cot':'off'}
|
||||
with patch.object(bridge,'CONNECTION',connection),patch.object(server,'OUTPUTS',root),patch.object(bridge,'request',return_value=raw.getvalue()) as request:
|
||||
result=server.render_gguf(job,spec)
|
||||
payload=request.call_args.args[1]
|
||||
self.assertEqual(payload['model'],'Synthetic Music');self.assertEqual(payload['steps'],6)
|
||||
self.assertEqual(payload['max_tokens'],9000);self.assertNotIn('abc',payload)
|
||||
saved=result.save_artifacts(root/'take');self.assertEqual(saved['audio_seconds'],1)
|
||||
self.assertTrue((root/'take/audio.flac').is_file())
|
||||
def test_unsupported_score_does_not_start_deck(self):
|
||||
with patch.object(bridge,'request') as request:
|
||||
with self.assertRaisesRegex(RuntimeError,'Score'):
|
||||
server.render_gguf(None,{'abc':'synthetic'})
|
||||
request.assert_not_called()
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY app.py index.html ./
|
||||
USER 65534:65534
|
||||
CMD ["python","app.py"]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Athena Audio-Trennung Web-GUI
|
||||
|
||||
Die bisherige Athena-GUI als CPU-only Container. Keine Modelle, CUDA oder Docker-Socket im Container. Native Verarbeitung durch Decks audio-separator 0.47.0.
|
||||
|
||||
Build: `docker build -t athena-separator-web:0.1.0 deploy/separator-web`
|
||||
|
||||
Start auf Debian nach Prüfung des freien Ports:
|
||||
|
||||
```sh
|
||||
sudo install -d -o 65534 -g 65534 -m 700 /opt/athena-separator-web/data
|
||||
docker run -d --name athena-separator-web --network host --restart unless-stopped \
|
||||
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
|
||||
--memory 2g --cpus 2 --tmpfs /tmp:rw,noexec,nosuid,size=1g,uid=65534,gid=65534 \
|
||||
--label io.athena-deck.managed=true --label io.athena-deck.role=application \
|
||||
--label io.athena-deck.application=separator-web --label io.athena-deck.port=8128 \
|
||||
-v /opt/athena-separator-web/data:/data:rw athena-separator-web:0.1.0
|
||||
```
|
||||
|
||||
GUI bindet nur 127.0.0.1:8128; Verbindung zu Decks Management-API auf 127.0.0.1:8108. In der GUI einmal Deck-API-Token eintragen. Er wird serverseitig mit Modus 0600 gespeichert und niemals zurückgegeben. Loopback/SSH-Tunnel benutzen.
|
||||
|
||||
Mac-Tunnel: `ssh -i ~/.ssh/athena_key -o BatchMode=yes -N -L 8128:127.0.0.1:8128 root@192.168.1.212`
|
||||
|
||||
URL http://127.0.0.1:8128/ . Stoppen/starten/löschen über Weitere Dienste oder docker stop/start/rm athena-separator-web. Volume enthält ausschließlich Verbindungsdaten. Audio wird temporär konvertiert; Deck-Ergebnisse sind nicht im Konfigurationsbackup enthalten.
|
||||
|
||||
Unterstützt Gesang (BS-Roformer), Schlagzeug/Bass (Demucs FT), Gitarre/Klavier/Rest (Demucs 6s). Kein ClearVoice. Upload bis 256 MiB, maximal 20 Minuten. Native Worker laufen pro Auftrag; dauerhaftes Warmhalten ist noch nicht implementiert. Bei fremder GPU-Belegung wird abgelehnt, keine fremden Dienste werden beendet.
|
||||
|
||||
Modellquellen einschließlich aller Ensemble-Dateien und SHA-256 stehen in separator_sources.json. Paket-Downloads und Restore prüfen alle Hashes. Quelle darf GitHub/UVR oder Meta sein; es wird keine Hugging-Face-Herkunft erfunden. Lokale Übernahme erhält IDs und wird separat als Übernahme dargestellt. Unbekannte lokale Dateien benötigen weiterhin einen belegten Herkunftsabgleich.
|
||||
|
||||
Backup speichert Paketrezepte, Quellen und Container-Konfiguration. Der Docker-Helfer kann das hier hinterlegte Image-Rezept auf einer leeren Installation wieder bauen; Gewichte werden von den geprüften Quellen erneut bezogen.
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Old Athena separation UI, remote Deck processing; no CUDA or models here."""
|
||||
import io,json,os,secrets,subprocess,tempfile,time,urllib.request,urllib.error,zipfile
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
BASE='http://127.0.0.1:8108'
|
||||
CONFIG=Path('/data/deck-connection.json')
|
||||
TARGETS={'vocals':'model_bs_roformer_ep_317_sdr_12.9755.ckpt','drums':'htdemucs_ft.yaml','bass':'htdemucs_ft.yaml','guitar':'htdemucs_6s.yaml','piano':'htdemucs_6s.yaml','other':'htdemucs_6s.yaml'}
|
||||
def request(path,body=None,ctype='application/json',token=None):
|
||||
if token is None:token=json.loads(CONFIG.read_text())['token'] if CONFIG.exists() else ''
|
||||
if not token:raise ValueError('Zuerst die Deck-Verbindung einrichten.')
|
||||
req=urllib.request.Request(BASE+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':ctype})
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=90) as response:return response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:message=json.loads(exc.read()).get('error','Deck-Anfrage abgelehnt')
|
||||
except Exception:message='Deck-Anfrage abgelehnt'
|
||||
raise ValueError(message if isinstance(message,str) else message.get('message','Deck-Anfrage abgelehnt')) from None
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self,*args):pass
|
||||
def send(self,body,status=200,mime='application/json'):
|
||||
if isinstance(body,dict):body=json.dumps(body).encode()
|
||||
self.send_response(status);self.send_header('Content-Type',mime);self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('X-Content-Type-Options','nosniff');self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'");self.end_headers();self.wfile.write(body)
|
||||
def do_GET(self):
|
||||
if self.path=='/health':return self.send({'status':'ok','connected':CONFIG.exists()})
|
||||
if self.path=='/':return self.send(Path('/app/index.html').read_bytes(),mime='text/html; charset=utf-8')
|
||||
if self.path=='/api/status':
|
||||
try:return self.send(request('/api/v1/separator-runtime'))
|
||||
except ValueError as exc:return self.send({'detail':str(exc)},400)
|
||||
return self.send({'detail':'Nicht gefunden'},404)
|
||||
def do_POST(self):
|
||||
origin=self.headers.get('Origin');host=self.headers.get('Host','')
|
||||
if self.headers.get('X-Athena-UI')!='1' or origin and origin!='http://'+host:return self.send({'detail':'Same-origin erforderlich'},403)
|
||||
try:
|
||||
length=int(self.headers.get('Content-Length','0'))
|
||||
if self.headers.get('Transfer-Encoding') or not 0<length<=256*1024**2:raise ValueError('Upload maximal 256 MiB.')
|
||||
self.connection.settimeout(60);raw=self.rfile.read(length)
|
||||
if len(raw)!=length:raise ValueError('Upload unvollständig.')
|
||||
if self.path=='/api/connection':
|
||||
if length>4096:raise ValueError('Verbindungsdaten zu groß.')
|
||||
value=json.loads(raw);token=value.get('token','')
|
||||
if not isinstance(token,str) or not 1<=len(token)<=256:raise ValueError('Ungültiger Token.')
|
||||
request('/api/v1/separator-runtime',token=token)
|
||||
CONFIG.parent.mkdir(exist_ok=True);tmp=CONFIG.with_suffix('.tmp');tmp.write_text(json.dumps({'token':token}));tmp.chmod(0o600);tmp.replace(CONFIG)
|
||||
return self.send({'ok':True})
|
||||
if self.path!='/v1/separate':return self.send({'detail':'Nicht gefunden'},404)
|
||||
ctype=self.headers.get('Content-Type','')
|
||||
if not ctype.startswith('multipart/form-data;'):raise ValueError('Audiodatei fehlt.')
|
||||
msg=BytesParser(policy=policy.default).parsebytes(b'Content-Type: '+ctype.encode()+b'\r\n\r\n'+raw)
|
||||
fields={}
|
||||
if not msg.is_multipart() or msg.defects:raise ValueError('Ungültiger Upload.')
|
||||
for part in msg.iter_parts():
|
||||
name=part.get_param('name',header='content-disposition')
|
||||
if name not in ('file','target') or name in fields:raise ValueError('Ungültiges Upload-Feld.')
|
||||
fields[name]=part.get_payload(decode=True)
|
||||
target=fields.get('target',b'').decode()
|
||||
if target not in TARGETS:raise ValueError('Zielspur noch nicht angebunden.')
|
||||
with tempfile.TemporaryDirectory() as work:
|
||||
source=Path(work)/'input';source.write_bytes(fields['file']);wav=Path(work)/'input.wav'
|
||||
subprocess.run(['ffmpeg','-nostdin','-hide_banner','-loglevel','error','-y','-i',str(source),'-t','1201','-ac','2','-ar','44100','-c:a','pcm_s16le',str(wav)],check=True,timeout=120,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
data=wav.read_bytes();boundary=secrets.token_hex(24)
|
||||
body=b''
|
||||
for name,value in [('model',TARGETS[target]),('target',target)]:body+=f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()
|
||||
body+=f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="input.wav"\r\nContent-Type: audio/wav\r\n\r\n'.encode()+data+f'\r\n--{boundary}--\r\n'.encode()
|
||||
job=json.loads(request('/api/v1/separator-jobs/start',body,'multipart/form-data; boundary='+boundary));ident=job['id'];deadline=time.monotonic()+7250
|
||||
while time.monotonic()<deadline:
|
||||
time.sleep(2);current=json.loads(request('/api/v1/separator-tests'))['job']
|
||||
if not current or current['id']!=ident:raise ValueError('Auftrag wurde ersetzt.')
|
||||
if current['state']=='running':continue
|
||||
if current['state']!='complete':raise ValueError(current['phase'])
|
||||
result=io.BytesIO()
|
||||
with zipfile.ZipFile(result,'w',zipfile.ZIP_STORED) as bundle:
|
||||
for f in current['files']:bundle.writestr(Path(f['name']).name,request(f"/api/v1/separator-tests/audio?id={ident}&index={f['index']}"))
|
||||
return self.send(result.getvalue(),mime='application/zip')
|
||||
raise ValueError('Trennauftrag hat das Zeitlimit überschritten.')
|
||||
except Exception as exc:return self.send({'detail':str(exc) if isinstance(exc,ValueError) else 'Verarbeitung fehlgeschlagen; Verbindung und Audioformat prüfen.'},400)
|
||||
if __name__=='__main__':ThreadingHTTPServer(('127.0.0.1',8128),Handler).serve_forever()
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Athena · Spuren herauslösen</title><style>
|
||||
:root{color-scheme:dark;--bg:#07111c;--card:#101d2b;--line:#26384b;--cyan:#48d7f5;--mint:#63e6be;--text:#ecf5ff;--muted:#91a4b7}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#142a42 0,#07111c 42%);font:16px system-ui,sans-serif;color:var(--text);min-height:100vh;display:grid;place-items:center;padding:24px}.card{width:min(880px,100%);padding:32px;border:1px solid var(--line);border-radius:22px;background:rgba(16,29,43,.96);box-shadow:0 25px 70px #0008}.eyebrow{color:var(--cyan);font-weight:800;letter-spacing:.14em;text-transform:uppercase;font-size:12px}h1{font-size:clamp(30px,5vw,52px);margin:.3em 0 .15em}p{color:var(--muted);line-height:1.6}.targets{display:grid;grid-template-columns:repeat(3,1fr);gap:9px;margin:24px 0}.target{display:block;border:1px solid var(--line);border-radius:14px;padding:14px 10px;text-align:center;cursor:pointer}.target:has(input:checked){border-color:var(--cyan);background:#48d7f510;box-shadow:0 0 0 1px #48d7f528}.target input{display:none}.target b,.target span{display:block}.target span{color:var(--muted);font-size:12px;margin-top:5px;line-height:1.35}.section{grid-column:1/-1;color:var(--cyan);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;margin-top:8px}.drop{display:block;margin:20px 0;padding:36px 24px;border:2px dashed #3f5a72;border-radius:18px;text-align:center;cursor:pointer;transition:.2s}.drop:hover,.drop.drag{border-color:var(--cyan);background:#48d7f50b}.drop input{display:none}.file{color:var(--mint);font-weight:700;margin-top:8px}button{width:100%;border:0;border-radius:13px;padding:15px;font-weight:800;font-size:16px;background:linear-gradient(90deg,var(--cyan),var(--mint));color:#05202a;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.status{min-height:28px;margin-top:18px;color:var(--muted)}.bar{height:7px;background:#07111c;border-radius:9px;overflow:hidden;margin-top:12px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--mint));transition:.4s}.run .fill{width:85%;animation:pulse 1.5s infinite alternate}@keyframes pulse{to{opacity:.45}}small{display:block;color:#71879a;margin-top:20px}@media(max-width:760px){.targets{grid-template-columns:repeat(2,1fr)}}
|
||||
</style></head><body><main class="card"><div class="eyebrow">Athena Audio Lab</div><h1>Was möchtest du herauslösen?</h1><p>Der Download enthält immer die gewählte Spur separat und zusätzlich den vollständigen Rest ohne diese Spur.</p>
|
||||
<details><summary>Deck-Verbindung einrichten</summary><p>API-Token aus Athena Deck → Zugang & API-Token. Er bleibt nur auf dem Server gespeichert.</p><input id="token" type="password" autocomplete="off" placeholder="Deck API-Token"><button id="connect">Verbinden</button></details><p id="packages">Modellpakete werden geprüft …</p><div class="targets">
|
||||
<div class="section">Musik</div>
|
||||
<label class="target"><input type="radio" name="target" value="vocals" checked><b>Gesang</b><span>BS‑RoFormer<br>Gesang / Instrumental</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="drums"><b>Schlagzeug</b><span>HTDemucs FT</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="bass"><b>Bass</b><span>HTDemucs FT</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="guitar"><b>Gitarre</b><span>HTDemucs 6s<br>experimentell</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="piano"><b>Piano</b><span>HTDemucs 6s<br>experimentell</span></label>
|
||||
<label class="target"><input type="radio" name="target" value="other"><b>Sonstiges</b><span>Synths, Streicher etc.<br>gemischte Spur</span></label>
|
||||
|
||||
|
||||
</div>
|
||||
<label class="drop" id="drop">Audio auswählen oder hier ablegen<input id="file" type="file" accept="audio/*"><div class="file" id="name">Noch keine Datei gewählt</div></label><button id="start" disabled>Ausgewählte Spur und Rest erzeugen</button><div class="status" id="status">Bereit.</div><div class="bar" id="bar"><div class="fill"></div></div><small>Verarbeitung durch Athena Deck. Bis 20 Minuten und 256 MiB; Ausgabe als WAV-ZIP. Modell wird nach dem Auftrag entladen. Synthesizer, Streicher sowie elektrische und akustische Gitarre separat benötigen zusätzliche Spezialmodelle.</small></main><script>
|
||||
const file=document.querySelector('#file'),drop=document.querySelector('#drop'),name=document.querySelector('#name'),start=document.querySelector('#start'),status=document.querySelector('#status'),bar=document.querySelector('#bar');let selected;async function packages(){let r=await fetch('/api/status'),s=await r.json();document.querySelector('#packages').textContent=r.ok?s.models.map(m=>`${m.name}: ${m.installed?'bereit':'fehlt – in Deck herunterladen'}`).join(' · '):s.detail}document.querySelector('#connect').onclick=async()=>{let token=document.querySelector('#token');let r=await fetch('/api/connection',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-UI':'1'},body:JSON.stringify({token:token.value})});let s=await r.json();token.value='';status.textContent=r.ok?'Deck verbunden.':s.detail;packages()};packages();const names={vocals:'gesang',drums:'schlagzeug',bass:'bass',guitar:'gitarre',piano:'piano',other:'sonstiges',speech:'sprache-und-hintergrund'};function choose(f){selected=f;name.textContent=f?`${f.name} · ${(f.size/1048576).toFixed(1)} MiB`:'Noch keine Datei gewählt';start.disabled=!f}file.onchange=()=>choose(file.files[0]);drop.ondragover=e=>{e.preventDefault();drop.classList.add('drag')};drop.ondragleave=()=>drop.classList.remove('drag');drop.ondrop=e=>{e.preventDefault();drop.classList.remove('drag');choose(e.dataTransfer.files[0])};start.onclick=async()=>{const target=document.querySelector('input[name=target]:checked').value;start.disabled=true;bar.classList.add('run');status.textContent=target==='speech'?'MossFormer2 trennt Sprache und Hintergrund – das kann einige Minuten dauern …':'Modell löst die gewählte Spur heraus – das kann einige Minuten dauern …';let body=new FormData();body.append('file',selected);body.append('target',target);try{let r=await fetch('/v1/separate',{method:'POST',headers:{'X-Athena-UI':'1'},body});if(!r.ok)throw Error((await r.json()).detail||`HTTP ${r.status}`);let blob=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`athena-${names[target]}-und-rest.zip`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000);status.textContent='Fertig – ZIP mit der ausgewählten Spur und dem Rest wurde geladen.'}catch(e){status.textContent=`Fehler: ${e.message}`}finally{bar.classList.remove('run');start.disabled=false}};
|
||||
</script></body></html>
|
||||
@@ -25,6 +25,12 @@ def main():
|
||||
(base/'swarm-ui').mkdir(exist_ok=True)
|
||||
for name in ('Dockerfile','patch-source.py','proxy.py','entrypoint.py'):
|
||||
shutil.copyfile(Path(__file__).parent/'swarm-ui'/name,base/'swarm-ui'/name)
|
||||
(base/'ladypoly').mkdir(exist_ok=True)
|
||||
for name in ('Dockerfile','bridge.py'):
|
||||
shutil.copyfile(Path(__file__).parent/'ladypoly'/name,base/'ladypoly'/name)
|
||||
(base/'separator-web').mkdir(exist_ok=True)
|
||||
for name in ('Dockerfile','app.py','index.html'):
|
||||
shutil.copyfile(Path(__file__).parent/'separator-web'/name,base/'separator-web'/name)
|
||||
if args.deck_state:
|
||||
registered=Path(args.deck_state).resolve()
|
||||
if not registered.is_dir():raise SystemExit('Deck-Zustandsverzeichnis fehlt.')
|
||||
|
||||
+14
-4
@@ -2,13 +2,23 @@ window.DockerUI=(()=>{
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(action='',data){const r=await fetch('/api/v1/docker'+action,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});if(r.status===401){location.reload();throw Error('Bitte erneut anmelden.');}const value=await r.json();if(!r.ok)throw Error(value.error||'Docker-Anfrage fehlgeschlagen');return value;}
|
||||
function html(services=false){return `<div class="kicker">${services?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}</div><h1>${services?'Weitere Dienste':'Docker'}</h1><p>${services?'Zusätzliche Oberflächen werden getrennt von Modellen und Laufzeiten verwaltet. Hier erscheinen ausschließlich ausdrücklich für Deck markierte Anwendungscontainer.':'Optionale Umgebung für zusätzliche Studios. Native Modelllaufzeiten bleiben unabhängig davon.'}</p><section id="docker-panel"></section><p id="docker-message" role="status"></p>`;}
|
||||
function bind(services=false){const panel=document.querySelector('#docker-panel'),message=document.querySelector('#docker-message');let signature='',pending=false;
|
||||
async function refresh(){try{const s=await api();if(!panel.isConnected)return;const next=JSON.stringify(s);if(signature!==next){signature=next;const running=s.job?.state==='running';
|
||||
if(services){panel.innerHTML=`<section class="card"><h2>Explizite Zuordnung</h2><p>Beide Container-Labels müssen gesetzt sein:</p><pre><code>io.athena-deck.managed=true\nio.athena-deck.role=application</code></pre><p class="small">Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.</p><a class="link" href="#docker-runtime">Docker-Verbindung prüfen →</a></section>${!s.helper_available||!s.reachable?'<section class="empty"><h2>Docker nicht verbunden</h2><p>'+esc(s.message)+'</p></section>':s.services.length?'<div class="grid">'+s.services.map(c=>`<section class="card"><span class="pill">DOCKER · DECK-ANWENDUNG</span><h2>${esc(c.name)}</h2><p>${esc(c.image)}</p><p><strong>${esc(c.state)}</strong> · ${esc(c.status)}</p>${c.name==='athena-swarm-ui'?`<p>API By URL · Deck steuert die Video-Laufzeit</p><a class="link" href="${esc(location.protocol+'//'+location.hostname+':8125/')}" target="_blank" rel="noopener">SwarmUI öffnen →</a><p class="small">In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.</p>`:''}</section>`).join('')+'</div>':'<section class="empty"><h2>Noch keine Deck-Anwendungen</h2><p>Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.</p></section>'}`;}
|
||||
function bind(services=false){const panel=document.querySelector('#docker-panel'),message=document.querySelector('#docker-message');let signature='',pending=false,timer;
|
||||
async function refresh(){clearTimeout(timer);try{if(pending)return;const s=await api();if(!panel.isConnected)return;const next=JSON.stringify(s);if(signature!==next){signature=next;const running=s.job?.state==='running';
|
||||
if(services){panel.innerHTML=`<section class="card"><h2>Explizite Zuordnung</h2><p>Beide Container-Labels müssen gesetzt sein:</p><pre><code>io.athena-deck.managed=true\nio.athena-deck.role=application</code></pre><p class="small">Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Starten und Stoppen betrifft nur markierte Container. Löschen entfernt den Container einschließlich seiner beschreibbaren Schicht; Images und Volumes bleiben erhalten.</p><a class="link" href="#docker-runtime">Docker-Verbindung prüfen →</a></section>${!s.helper_available||!s.reachable?'<section class="empty"><h2>Docker nicht verbunden</h2><p>'+esc(s.message)+'</p></section>':s.services.length?'<div class="grid">'+s.services.map(c=>`<section class="card"><span class="pill">DOCKER · DECK-ANWENDUNG</span><h2>${esc(c.name)}</h2><p>${esc(c.image)}</p><p><strong>${esc(c.state)}</strong> · ${esc(c.status)}</p>${c.name==='athena-ladypoly'?`<p>LadyPoly YuE2 · Oberfläche für Decks Musiklaufzeit</p><a class="link" href="${esc(location.protocol+'//'+location.hostname+':8127/')}" target="_blank" rel="noopener">LadyPoly öffnen →</a><p class="small">Zuerst Deck-Verbindung einrichten und in der Übersicht Musik aktivieren. SSH-Tunnel: Port 8127.</p>`:''}${c.name==='athena-swarm-ui'?`<p>API By URL · Deck steuert die Video-Laufzeit</p><a class="link" href="${esc(location.protocol+'//'+location.hostname+':8125/')}" target="_blank" rel="noopener">SwarmUI öffnen →</a><p class="small">In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.</p>`:''}<div class="card-actions"><button data-container-action="start" data-container-id="${esc(c.id)}" ${c.state==='running'?'disabled':''}>Starten</button><button class="secondary" data-container-action="stop" data-container-id="${esc(c.id)}" ${c.state!=='running'?'disabled':''}>Stoppen</button><button class="secondary danger" data-container-action="delete" data-container-id="${esc(c.id)}" ${!['exited','created','dead'].includes(c.state)?'disabled':''}>Löschen</button></div></section>`).join('')+'</div>' :'<section class="empty"><h2>Noch keine Deck-Anwendungen</h2><p>Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.</p></section>'}`;}
|
||||
else {panel.innerHTML=`<section class="card"><span class="pill">${s.installed?'INSTALLIERT':s.installed===false?'NICHT INSTALLIERT':'NICHT ERMITTELBAR'}</span><h2>Docker Engine ${esc(s.version||'')}</h2><p>${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}</p><p>${esc(s.message)}</p>${s.job?`<p role="status">${esc(s.job.state)} · ${esc(s.job.phase)}</p>`:''}${!s.installed&&s.install_supported&&!running?'<label><input type="checkbox" id="docker-install-ack"> Docker Engine, Compose und Buildx auf diesem Server installieren. Docker richtet einen Dienst und Netzwerk-/Firewallregeln ein.</label><p><button id="docker-install" disabled>Docker installieren</button></p>':`<button disabled>${running?'Installation läuft …':s.installed?'Docker bereits vorhanden':'Installation hier nicht verfügbar'}</button>`}<p class="small">Keine automatische Aktualisierung vorhandener Docker-Installationen. NVIDIA Container Toolkit und Treiber sind separate Voraussetzungen für GPU-Container. Die Paketinstallation wird nicht mitten im Vorgang abgebrochen.</p><a class="link" href="#services">Weitere Dienste öffnen →</a></section>`;
|
||||
const button=panel.querySelector('#docker-install'),ack=panel.querySelector('#docker-install-ack');if(button){ack.onchange=()=>button.disabled=pending||!ack.checked;button.onclick=async()=>{pending=true;button.disabled=true;try{await api('/install',{confirm:true});message.textContent='Docker-Erstinstallation gestartet.';}catch(error){message.textContent=error.message;}finally{pending=false;signature='';}};}
|
||||
}
|
||||
}}catch(error){if(panel.isConnected){message.textContent=error.message;signature='';}}finally{if(panel.isConnected)setTimeout(refresh,5000);}}
|
||||
}
|
||||
if(services)panel.querySelectorAll('[data-container-action]').forEach(b=>b.onclick=async()=>{
|
||||
const action=b.dataset.containerAction,id=b.dataset.containerId,c=s.services.find(c=>c.id===id);
|
||||
if(pending||!c)return;
|
||||
if(action==='delete'&&!confirm(`Container „${c.name}“ endgültig löschen? Die beschreibbare Container-Schicht geht verloren. Images und Volumes bleiben erhalten.`))return;
|
||||
pending=true;panel.querySelectorAll('button').forEach(x=>x.disabled=true);message.textContent=action==='stop'?'Container wird gestoppt (bis zu 30 Sekunden) …':action==='start'?'Container wird gestartet …':'Container wird gelöscht …';
|
||||
try{await api('/'+action,{id,...(action==='delete'?{confirm:true}:{})});message.textContent=action==='delete'?'Container gelöscht. Images und Volumes bleiben erhalten.':action==='start'?'Container gestartet.':'Container gestoppt.';}
|
||||
catch(error){message.textContent=error.message;}
|
||||
finally{pending=false;signature='';if(panel.isConnected)refresh();}
|
||||
});
|
||||
}catch(error){if(panel.isConnected){message.textContent=error.message;signature='';}}finally{if(panel.isConnected)timer=setTimeout(refresh,5000);}}
|
||||
refresh();
|
||||
}
|
||||
return {html,bind};
|
||||
|
||||
@@ -20,3 +20,7 @@ class DockerSupport:
|
||||
try:return dict(self.call('status'),helper_available=True)
|
||||
except (OSError,ValueError):return dict(helper_available=False,installed=None,reachable=False,services=[],install_supported=False,job=None,message='Docker-Systemhelfer nicht verbunden. Der Deck-Installer muss ihn auf dem Debian-Host einrichten.',labels=[MANAGED_LABEL,ROLE_LABEL])
|
||||
def install(self):return self.call('install')
|
||||
|
||||
def container_action(self,action,ident):
|
||||
if action not in ('start','stop','delete'):raise ValueError('Ungültige Containeraktion.')
|
||||
return self.call('container-'+action,ident)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# YuE2 über audio.cpp
|
||||
|
||||
Deck führt die offizielle YuE2-GGUF-Familie über die installierte audio.cpp-CLI aus. Keine neue Laufzeitinstallation oder Docker-Container nötig. BF16, Q8_0 und Q4_0-Hauptdateien aus audio-cpp/Yue2-3B-GGUF werden mit zugeordnetem VAE und vier Sidecars unterstützt.
|
||||
|
||||
## Bedienung
|
||||
|
||||
Musik → Profile → Einrichtung & Komponenten: fehlende Dateien herunterladen und zuordnen. Danach kann das Profil am API-Endpunkt freigegeben werden. Unter Musik → Testen ein ausführbares Profil wählen, Musikstil und Songtext eingeben und Musik erzeugen. Status, Laufzeit, Abbruch, Fehler und WAV-Ergebnis sind sichtbar. Englische Texte werden empfohlen. Standardtest: cot=off, 400 semantische Tokens, 8 NAR-Schritte, Seed 1234. Tokenlimit ist keine garantierte Sekundenlänge. Bis 9000 Tokens und 32 NAR-Schritte sind einstellbar.
|
||||
|
||||
Deck reserviert exklusiv seinen GPU-Auftrag, entlädt sein LLM und verdrängt eigenes TTS. Fremde Prozesse werden nicht beendet. Die freie RTX 5080 wird verwendet; keine automatische CPU-Auslagerung. Video-Modus sperrt Musik. Nach Abschluss/Fehler/Abbruch endet der CLI-Prozess und gibt GPU-Speicher frei. Prompts und vorübergehende Worker-Ausgabe werden nicht dauerhaft gespeichert; WAV-Ergebnisse bleiben in music-jobs. Installation und Profilrezept sind Bestandteil der vorhandenen Wiederherstellung; Ergebnis-Audio nicht.
|
||||
|
||||
## API
|
||||
|
||||
Dies ist eine Deck-Musik-Erweiterung, kein standardisierter OpenAI-Musikendpunkt. Gleicher API-Port und Bearer-Token wie Chat, getrennte Modellliste:
|
||||
|
||||
- GET /v1/audio/music/models: nur aktivierte ausführbare Musikprofile.
|
||||
- POST /v1/audio/music: JSON mit model (Profilname), lyrics, style und optional seed, max_tokens, steps; gibt audio/wav zurück. Authentifiziert und GPU-koordiniert. Maximales Worker-Zeitlimit 30 Minuten.
|
||||
- GET /v1/models bleibt ausschließlich für Chatmodelle.
|
||||
|
||||
Interne GUI-API mit Deck-Anmeldung/CSRF: GET /api/v1/music; POST /api/v1/music/start mit profile_id, lyrics, style und optionalen Testparametern; POST /api/v1/music/cancel mit {}; GET /api/v1/music/audio?id=JOB_ID.
|
||||
|
||||
Die Ladypoly-WebUI ist hiermit noch nicht verbunden. Der Deck-Testbereich und die Musik-API funktionieren unabhängig davon. Weitere Musikfamilien oder LoRA/ABC-Funktionen sind noch nicht angebunden.
|
||||
|
||||
## Verifikation auf Athena
|
||||
|
||||
2026-09-30: YuE2 BF16 mit zugeordnetem F16-VAE und Sidecars erzeugte bei 200 semantischen Tokens / 2 NAR-Schritten eine gültige stereo WAV von 8 Sekunden in etwa 18 Sekunden auf der RTX 5080. Danach Prozess beendet und GPU-Speicher freigegeben. Dies prüft Ausführung und Dateiformat, nicht musikalische Qualität oder Stabilität langer Aufträge.
|
||||
|
||||
## Exklusiver Musik-Modus
|
||||
|
||||
Übersicht → GPU-Modus bietet LLM, Video und Musik. Musik setzt mindestens ein am Endpunkt freigegebenes ausführbares Musikprofil voraus. Beim Wechsel werden eigene Deck-GPU-Worker beendet und laufende GPU-Reservierungen freigegeben; fremde Videodienste werden geprüft, aber nie gestoppt. Musikgewichte laden weiterhin erst bei Anfrage. Im Musikmodus werden Chat/Bild/TTS/STT blockiert; für diese Aufträge auf LLM zurückschalten. Musik → Testen zeigt einen Umschalthinweis, solange ein anderer Modus aktiv ist.
|
||||
|
||||
## Statusabfragen und parallele GUI-Zugriffe
|
||||
|
||||
Endpunkt- und Video-Status können gleichzeitig von Übersicht, Bibliothek und
|
||||
Profilen angefragt werden. Der Video-Status liest auch die freigegebenen
|
||||
Musikprofile. Deshalb darf der Endpunkt seine eigene Sperre nicht halten,
|
||||
während er den Video-Status abfragt: die umgekehrte Reihenfolge würde beide
|
||||
Abfragen gegenseitig blockieren. Ein Regressionstest prüft den gleichzeitigen
|
||||
Profilzugriff während der Video-Statusabfrage. Der Fehler ließ vorhandene
|
||||
Bibliotheken leer erscheinen; Gewichte, Downloadhistorie und Profile wurden
|
||||
dabei nicht verändert.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Laufzeiten erkennen und vorbereiten
|
||||
|
||||
In **Bibliothek → Modell** und bei der Einrichtung von Profilen zeigt Deck die erkannte Modellfamilie, den Ausführungsstatus und **Mögliche Laufzeiten**. Das gilt für Sprache/Chat, Bild, TTS, STT, Musik, Stimme und Video.
|
||||
|
||||
Eine vorhandene Laufzeit wird wiederverwendet. Ein fehlender Worker oder Workflow bleibt ausdrücklich sichtbar. Unbekannte Familien erhalten nur gekennzeichnete Recherchehinweise aus ihrer Kategorie; das ist keine Kompatibilitätszusage. Es wird nichts automatisch installiert.
|
||||
|
||||
Über den Link „Laufzeit herunterladen / bereitmachen“ gelangt man zur jeweiligen Laufzeitseite. Dort wird die Installation bewusst gestartet. Ohne gepflegten Installer zeigt Deck dies an, statt eine Installation vorzutäuschen.
|
||||
|
||||
## YuE2 GGUF / audio.cpp
|
||||
|
||||
`audio-cpp/Yue2-3B-GGUF`, Hauptdateien `yue2-3b-*.gguf`, werden als YuE2 erkannt. Die VAE-Dateien sind Komponenten, keine eigenständigen Modelle. Das Paket braucht zusätzlich VAE, Modell-/Generierungskonfiguration, VAE-Konfiguration und Qwen-Tokenizer; `.tiktoken` ist herunterladbar.
|
||||
|
||||
**Einstellungen → Laufzeiten → audio.cpp** bietet einen isolierten CUDA-Build aus `0xShug0/audio.cpp`, Version v0.8.1, Commit `f2b4937306daa25f5c78520f3c626ed31495a37a`. Voraussetzung: Debian/Linux, Git, CMake, g++, nvcc, nvidia-smi und mindestens 6 GiB frei. GPU-Architekturen werden gelesen; gebaut wird mit sechs Jobs. Keine Host-Pakete, Treiber oder bestehenden Laufzeiten werden verändert. Fehlende Voraussetzungen werden gemeldet.
|
||||
|
||||
Installation liegt im Deck-Zustandsverzeichnis unter `audio-cpp-runtime`. Status, Abbruch und eigene Build-Ausgabe sind in der GUI verfügbar. Der CLI-Start wird ohne Modell geprüft. Dieser Build lädt **keine Gewichte**. Der ausführbare YuE2-Deck-Musikworker ist angebunden; Bedienung und API sind in MUSIC_API.md dokumentiert. Die Ladypoly-WebUI-Anbindung ist separat. Auch andere audio.cpp-Familien brauchen geprüfte modellspezifische Anbindungen.
|
||||
|
||||
Interne Admin-API: `GET /api/v1/audio-cpp-runtime`, `GET /api/v1/audio-cpp-runtime/log`, `POST /api/v1/audio-cpp-runtime/install` und `/cancel` (JSON `{}`, vorhandene Sitzung und CSRF-Header). Backup/Restore enthält das gepinnte Laufzeitrezept und baut es bei Bedarf neu; keine Binärdateien im Backup.
|
||||
|
||||
## YuE2-Komponenten in der GUI
|
||||
|
||||
Unter **Musik → Profile → Komponenten** bietet Deck für das offizielle YuE2-GGUF-Hauptmodell fünf Rollen: VAE (F16 empfohlen oder F32), Modellkonfiguration, Generierungskonfiguration, Qwen-Tokenizer und VAE-Konfiguration. „Alle fehlenden Dateien herunterladen“ reiht je eine Datei pro Rolle in die Download-Warteschlange ein. Danach „Bibliothek aktualisieren“ und „Zuordnung auf Athena speichern“. Bereits vorhandene Dateien werden wiederverwendet. Quelle und Zuweisung müssen dieselbe Hub-Revision wie das Hauptmodell haben. Diese Zusatzdateien sind keine eigenständigen Modelle. Die Ausführungsprüfung bleibt auch nach vollständiger Zuweisung als Blocker sichtbar.
|
||||
|
||||
## Gemeinsame Einrichtung in allen Kategorien
|
||||
|
||||
Alle Profile haben „Einrichtung & Komponenten“. Zuerst wird ein gepflegtes Modellrezept verwendet. Fehlt dieses, können installierte audio.cpp-Paketdefinitionen für exakt passende audio-cpp-Repositories Zusatzdateien liefern. Ohne vollständiges Rezept zeigt Deck Dateinamen-Hinweise der Modellquelle als **ungeprüft** an. Ein anderer Hugging-Face-Link kann als Komponentenquelle ausgewählt werden. Vorhandene Zusatzdateien lassen sich in allen Kategorien zuordnen. Manuelle Rollen sind optional und werden niemals automatisch als kompatibel angenommen; ihre Zuordnung blockiert die Ausführung bis zur passenden Worker-Anbindung. Beim Wechsel zur manuellen Quelle bleiben vorhandene Rezept-Zuordnungen erhalten.
|
||||
|
||||
Die Entdecken-Dateiansicht zeigt benötigte Komponenten aus bekannten Rezepten und Laufzeit-Paketen bereits vor dem Hauptdownload; weitere sichtbare Projektoren, Encoder und VAE werden als ungeprüfte Hinweise aufgeführt. Es gibt keine universelle, vollständige Erkennung aller Hub-Modelle.
|
||||
|
||||
Download-Annahme und Fehler erscheinen direkt neben der betroffenen Komponente. Die 10-GiB-Diskreserve bleibt erhalten. Der Installer behält nach erfolgreichen Updates nur die zwei neuesten automatischen Zustands-Sicherungen; manuell exportierte Backup-Dateien werden nicht angefasst.
|
||||
+44
-5
@@ -1,22 +1,61 @@
|
||||
window.EndpointUI=(()=>{
|
||||
const e=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const stateName={running:'Läuft',stopped:'Gestoppt',stopping:'Wird gestoppt · laufende Aufträge werden beendet',loading:'Modell lädt',ready:'Modell bereit',failed:'Fehlgeschlagen'};
|
||||
let pending=false,sequence=0;
|
||||
let pending=false,sequence=0,selectedMode=null,controlTimer=null;
|
||||
async function api(path='',data){const r=await fetch('/api/v1/endpoint'+path,data!==undefined?{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||'Endpunkt nicht erreichbar');return v;}
|
||||
function put(el,html){if(el._deckHtml!==html){el.innerHTML=html;el._deckHtml=html;}}
|
||||
function summary(s){return `<div class="grid"><section class="card wide"><div class="card-top"><h2>OpenAI-kompatibler Endpunkt</h2><span class="pill">${e(stateName[s.state])}</span></div><div class="metrics"><div><small>Erreichbarkeit</small><strong>${s.reachable?'Listener bereit':'Nicht erreichbar'}</strong></div><div><small>Lokaler API-Port</small><strong>${s.port}</strong></div><div><small>Laufende / wartende Aufträge</small><strong>${s.scheduler.active_requests} / ${s.scheduler.waiting_requests}</strong></div></div><p><code>${e(s.base_url)}</code> · <a class="link" href="#endpoint">Port und Profile einstellen →</a></p><p>Geladen: ${e(s.worker.profile_name||'Kein Sprachmodell')} · ${e(stateName[s.worker.state]||s.worker.state)}</p>${s.worker.error||s.error?`<p role="alert">${e(s.worker.error||s.error)}</p>`:''}<p>Aktivierte Profile werden angeboten. Erst eine Anfrage lädt das Modell. Wechsel warten auf laufende Antworten; Bildaufträge entladen vorher das eigene Sprachmodell.</p></section>${[['llm','Sprachmodelle'],['image','Bildgenerierung'],['tts','Text → Sprache (TTS)'],['stt','Sprache → Text (STT)']].map(([key,label])=>{const c=s.counts[key];return `<section class="card"><h3>${label}</h3><p>${c.supported?`${c.enabled} Profile aktiviert · ${c.available} ausführbar`:'Noch keine eigene Laufzeit eingerichtet'}</p><span class="pill">${c.loaded?'AKTIV IM SPEICHER':c.available?'BEI ANFRAGE LADEN':'NICHT AKTIV'}</span></section>`}).join('')}</div>`;}
|
||||
function summary(s){return `<div class="grid"><section class="card wide"><div class="card-top"><h2>OpenAI-kompatibler Endpunkt</h2><span class="pill">${e(stateName[s.state])}</span></div><div class="metrics"><div><small>Erreichbarkeit</small><strong>${s.reachable?'Listener bereit':'Nicht erreichbar'}</strong></div><div><small>Lokaler API-Port</small><strong>${s.port}</strong></div><div><small>Laufende / wartende Aufträge</small><strong>${s.scheduler.active_requests} / ${s.scheduler.waiting_requests}</strong></div></div><p><code>${e(s.base_url)}</code> · <a class="link" href="#endpoint">Port und Profile einstellen →</a></p><p>Geladen: ${e(s.worker.profile_name||'Kein Sprachmodell')} · ${e(stateName[s.worker.state]||s.worker.state)}</p>${s.worker.error||s.error?`<p role="alert">${e(s.worker.error||s.error)}</p>`:''}<p>Aktivierte Profile werden angeboten. Erst eine Anfrage lädt das Modell. Wechsel warten auf laufende Antworten; Bildaufträge entladen vorher das eigene Sprachmodell.</p></section>${[['llm','Sprachmodelle'],['image','Bildgenerierung'],['tts','Text → Sprache (TTS)'],['stt','Sprache → Text (STT)'],['music','Musik']].map(([key,label])=>{const c=s.counts[key];return `<section class="card"><h3>${label}</h3><p>${c.supported?`${c.enabled} Profile aktiviert · ${c.available} ausführbar`:'Noch keine eigene Laufzeit eingerichtet'}</p><span class="pill">${c.loaded?'AKTIV IM SPEICHER':c.available?'BEI ANFRAGE LADEN':'NICHT AKTIV'}</span></section>`}).join('')}</div>`;}
|
||||
const modeLabels={llm:'LLM',video:'Video',music:'Musik'};
|
||||
function control(s,root){
|
||||
const v=s.video||{},active=v.mode||'llm';
|
||||
if(!selectedMode)selectedMode=active==='switching'?(v.target_mode||'llm'):active;
|
||||
const selected=selectedMode,label=modeLabels[selected],switching=v.state==='switching',busy=pending||switching||s.state==='stopping';
|
||||
const current=selected===active,available=selected==='video'?Boolean(v.service?.id):Boolean(s.counts[selected]?.available);
|
||||
const ready=current&&s.reachable&&!switching&&(selected!=='video'||v.service?.ready);
|
||||
const loading=current&&selected==='llm'&&s.worker.state==='loading';
|
||||
const address=location.protocol+'//'+location.hostname+':'+s.port;
|
||||
const routes=selected==='llm'?[['Chat',address+'/v1/chat/completions'],['Modelle',address+'/v1/models']]:selected==='music'?[['Musik erzeugen',address+'/v1/audio/music'],['Musikmodelle',address+'/v1/audio/music/models']]:[['Video-API',address+'/']];
|
||||
const reason=available?'':selected==='video'?'Wähle zuerst ein eingerichtetes Videomodell.':'Gib zuerst ein ausführbares Profil am API-Endpunkt frei.';
|
||||
const state=switching?'Wechsel läuft':loading?'Modell wird geladen':ready?'Bereit':current&&s.state==='stopping'?'Wird gestoppt':current&&s.reachable?'Startet':current?'Gestoppt':'Nicht aktiv';
|
||||
put(root.querySelector('#control-modes'),`<div class="control-modes" role="tablist" aria-label="Betriebsart">${Object.entries(modeLabels).map(([id,name])=>`<button role="tab" aria-selected="${selected===id}" data-control-mode="${id}" class="${selected===id?'chosen':'secondary'}" ${busy?'disabled':''}>${name}</button>`).join('')}</div>`);
|
||||
put(root.querySelector('#endpoint-summary'),`<section class="card control-card" role="tabpanel"><div class="control-heading"><h2>${label}</h2><span class="pill">${e(state)}</span></div><p>${active==='switching'?`Wechsel zu ${e(modeLabels[v.target_mode]||v.target_mode)}`:s.reachable?`Aktiver Betrieb: ${e(modeLabels[active]||active)}`:'Betrieb gestoppt'}</p><div class="card-actions"><button data-control-start ${busy||!available||ready?'disabled':''}>${label} starten</button><button data-control-stop class="secondary" ${busy||!current||(s.state==='stopped'&&active==='llm')?'disabled':''}>Stoppen</button></div>${switching||loading||pending||s.state==='stopping'?`<div class="video-activity" role="status" aria-live="polite"><strong>${e(switching?v.switch_phase:loading?s.worker.phase||'Modell wird geladen':s.state==='stopping'?'Laufende Aufträge werden beendet':'Start wird angefordert')}</strong><progress aria-label="Fortschritt"></progress></div>`:''}${reason?`<p class="warning">${e(reason)} <a class="link" href="#${selected==='video'?'video':selected==='music'?'music':'chat'}">Einrichten →</a></p>`:''}${(current&&(v.error||s.error||selected==='llm'&&s.worker.error))?`<p class="video-error" role="alert">${e(v.error||s.error||s.worker.error)}</p>`:''}<div class="control-api"><h3>API-Adresse</h3>${routes.map(([name,url])=>`<div><span>${name}</span><code>${e(url)}</code></div>`).join('')}<p class="small">${ready?'Erreichbar':'Erreichbar, sobald dieser Betrieb bereit ist'} · Port ${s.port}</p></div>${selected==='llm'?`<p class="small">${s.counts.llm.available} Chatprofile freigegeben · ${e(s.worker.profile_name||'Gewichte laden bei der ersten Anfrage')}</p>`:selected==='music'?`<p class="small">${e(v.music_profiles?.map(p=>p.name).join(', ')||'Kein Musikprofil freigegeben')} · Gewichte laden bei Anfrage</p>${available?`<a class="link" href="${e(location.protocol+'//'+location.hostname+':8127/')}" target="_blank" rel="noopener">LadyPoly YuE2 öffnen →</a>${!ready?'<p class="small">Vor dem Generieren Musik starten.</p>':''}`:''}`:`<p class="small">${e(v.service?.name||'Kein Videomodell ausgewählt')} · ${v.runtime_kind==='original'?'LTX Original':'ComfyUI'} · Gewichte laden bei Generierung</p>${ready?`<a class="link" href="${e(v.runtime_kind==='original'?location.protocol+'//'+location.hostname+':8118/':address+'/')}" target="_blank" rel="noopener">${v.runtime_kind==='original'?'LTX DeskWEB':'ComfyUI'} öffnen →</a>`:''}`}<details class="control-details"><summary>Weitere Dienste & Einstellungen</summary><p>Im LLM-Betrieb stehen auch freigegebene Bild-, TTS- und STT-Profile bereit.</p><p><a class="link" href="#endpoint">Endpunkt und Port einstellen →</a></p><p>${s.scheduler.active_requests} laufende · ${s.scheduler.waiting_requests} wartende Aufträge</p></details></section>`);
|
||||
root.querySelectorAll('[data-control-mode]').forEach(b=>b.onclick=()=>{selectedMode=b.dataset.controlMode;control(s,root);});
|
||||
root.querySelector('[data-control-start]').onclick=()=>controlAction('start',selected);
|
||||
root.querySelector('[data-control-stop]').onclick=()=>controlAction('stop',selected);
|
||||
}
|
||||
async function switchMode(mode){
|
||||
const r=await fetch('/api/v1/video/mode',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({mode})});
|
||||
const result=await r.json();if(!r.ok)throw Error(result.error||'Wechsel fehlgeschlagen');return result;
|
||||
}
|
||||
async function controlAction(action,mode){
|
||||
if(pending)return;pending=true;const root=document.querySelector('#endpoint-page');
|
||||
root.querySelectorAll('button').forEach(b=>b.disabled=true);
|
||||
root.querySelector('#endpoint-message').innerHTML=`<div class="video-activity" role="status"><strong>${action==='start'?e(modeLabels[mode])+' wird gestartet':'Betrieb wird gestoppt'}</strong><progress aria-label="Fortschritt"></progress></div>`;
|
||||
try{
|
||||
const s=await api();
|
||||
if(action==='start'){
|
||||
if((s.video?.mode||'llm')!==mode||(mode==='video'&&!s.video?.service?.ready))await switchMode(mode);
|
||||
if(s.state==='stopped')await api('/start',{});
|
||||
}else{
|
||||
await api('/stop',{});
|
||||
if(s.video&&s.video.mode!=='llm')await switchMode('llm');
|
||||
}
|
||||
if(root.isConnected)root.querySelector('#endpoint-message').textContent='';
|
||||
}catch(error){if(root.isConnected)root.querySelector('#endpoint-message').textContent=error.message;}
|
||||
finally{pending=false;if(root.isConnected){root.querySelector('#control-modes')._deckHtml=null;root.querySelector('#endpoint-summary')._deckHtml=null;render(false);}}
|
||||
}
|
||||
async function render(settings=false){
|
||||
const view=document.querySelector('#view');let root=view.querySelector('#endpoint-page');
|
||||
if(!root||root.dataset.settings!==String(settings)){
|
||||
sequence++;view.innerHTML=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt & Profile':'Dein Modell-Endpunkt'}</h1><p>Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></div><p id="endpoint-message" role="status"></p><div id="video-mode-panel"></div><div id="endpoint-summary"></div>${settings?'<section class="card"><h2>Lokaler API-Port</h2><form id="endpoint-port-form"><label>Port<input id="endpoint-port" name="port" type="number" required min="1024" max="65535"></label><button id="endpoint-port-save">Port speichern</button></form><p id="endpoint-port-help"></p><p>Der Zugriff verwendet den API-Token aus <a class="link" href="#access">Zugang & API</a>. Verwaltungsoberfläche und API haben getrennte Ports.</p></section><section class="card"><h2>Am Endpunkt angebotene Profile</h2><p>Nur explizit aktivierte, ausführbare Profile erscheinen in <code>GET /v1/models</code>. Aktivieren lädt noch kein Modell.</p><div id="endpoint-profiles"></div></section><section class="card"><h2>API-Routen</h2><p><code>POST /v1/chat/completions</code> · Text und Streaming<br><code>POST /v1/images/generations</code> · Qwen-Image-Rezept, ein Bild als b64_json<br><code>POST /v1/audio/speech</code> und <code>POST /v1/audio/transcriptions</code> · noch nicht eingerichtet (501)</p><p>Alle Routen nutzen denselben Port und Bearer-Token. Bei Bildaufträgen bestimmt das Profil Auflösung, Schritte, Guidance und Seed. Noch keine Bildbearbeitung oder Vision-Eingaben.</p></section>':''}</section>`;
|
||||
sequence++;clearTimeout(controlTimer);selectedMode=null;view.innerHTML=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt':'Steuerung'}</h1>${settings?'<p>API-Port und freigegebene Profile verwalten.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></div>':'<p>Betriebsart wählen und starten.</p><div id="control-modes"></div>'}<p id="endpoint-message" role="status"></p><div id="video-mode-panel"></div><div id="endpoint-summary"></div>${settings?'<section class="card"><h2>Lokaler API-Port</h2><form id="endpoint-port-form"><label>Port<input id="endpoint-port" name="port" type="number" required min="1024" max="65535"></label><button id="endpoint-port-save">Port speichern</button></form><p id="endpoint-port-help"></p><p>Der Zugriff verwendet den API-Token aus <a class="link" href="#access">Zugang & API-Token</a>. Verwaltungsoberfläche und API haben getrennte Ports.</p></section><section class="card"><h2>Am Endpunkt angebotene Profile</h2><p>Nur explizit aktivierte, ausführbare Profile erscheinen in <code>GET /v1/models</code>. Aktivieren lädt noch kein Modell.</p><div id="endpoint-profiles"></div></section><section class="card"><h2>API-Routen</h2><p><code>POST /v1/chat/completions</code> · Text und Streaming<br><code>POST /v1/images/generations</code> · Qwen-Image-Rezept, ein Bild als b64_json<br><code>POST /v1/audio/speech</code> und <code>POST /v1/audio/transcriptions</code> · noch nicht eingerichtet (501)</p><p>Alle Routen nutzen denselben Port und Bearer-Token. Bei Bildaufträgen bestimmt das Profil Auflösung, Schritte, Guidance und Seed. Noch keine Bildbearbeitung oder Vision-Eingaben.</p></section>':''}</section>`;
|
||||
root=view.querySelector('#endpoint-page');
|
||||
for(const action of ['start','stop'])root.querySelector('#endpoint-'+action).onclick=()=>mutate('/'+action,{});
|
||||
if(settings)for(const action of ['start','stop'])root.querySelector('#endpoint-'+action).onclick=()=>mutate('/'+action,{});
|
||||
root.querySelector('#endpoint-port-form')?.addEventListener('submit',event=>{event.preventDefault();mutate('/config',{port:Number(root.querySelector('#endpoint-port').value)});});
|
||||
}
|
||||
if(pending)return;const id=sequence;
|
||||
try{const s=await api();if(!root.isConnected||id!==sequence)return;
|
||||
if(!settings){control(s,root);clearTimeout(controlTimer);if(s.video?.state==='switching'||s.worker.state==='loading'||s.state==='stopping')controlTimer=setTimeout(()=>{if(root.isConnected)render(false);},1000);return;}
|
||||
put(root.querySelector('#endpoint-summary'),summary(s));
|
||||
if(s.video)VideoUI.mode(root.querySelector('#video-mode-panel'),s.video,s.profiles,s.port);
|
||||
root.querySelector('#endpoint-start').disabled=s.state!=='stopped';root.querySelector('#endpoint-stop').disabled=s.state!=='running';
|
||||
if(settings){const port=root.querySelector('#endpoint-port');if(document.activeElement!==port)port.value=s.port;port.disabled=s.state!=='stopped';root.querySelector('#endpoint-port-save').disabled=s.state!=='stopped';
|
||||
root.querySelector('#endpoint-port-help').textContent=s.allowed_ports.length?'Docker-Testinstallation: veröffentlichte Loopback-Ports '+s.allowed_ports.join(', ')+'. Portwechsel nur bei gestopptem Endpunkt. Der SSH-Tunnel muss denselben Port weiterleiten.':'Nativer Dienst: freie Ports zwischen 1024 und 65535, nur bei gestopptem Endpunkt.';
|
||||
|
||||
+31
-9
@@ -39,7 +39,7 @@ def chat_upstream_error(status, raw):
|
||||
|
||||
class Endpoint:
|
||||
def __init__(self,root,profiles,worker,scheduler,images,credentials,management_port):
|
||||
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.stt=None;self.video=None;self.credentials=credentials
|
||||
self.root=Path(root);self.profiles=profiles;self.worker=worker;self.scheduler=scheduler;self.images=images;self.tts=None;self.stt=None;self.music=None;self.video=None;self.credentials=credentials
|
||||
self.management_port=management_port;self.lock=threading.RLock();self.http=None;self.thread=None;self.state='stopped';self.error=None;self.inflight=0
|
||||
self.allowed_ports=[int(p) for p in os.environ.get('DECK_API_PORTS','').split(',') if p]
|
||||
self.config=dict(port=self.allowed_ports[0] if self.allowed_ports else 8120,enabled_profiles=[],autostart=False)
|
||||
@@ -58,11 +58,14 @@ class Endpoint:
|
||||
return [dict(p,enabled=p['id'] in enabled) for p in rows if p['kind']!='video']
|
||||
def status(self):
|
||||
rows=self.rows();worker=self.worker.status();job=self.images.status()['job'];counts={}
|
||||
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt')]:
|
||||
for key,kind in [('llm','chat'),('image','image'),('tts','audio'),('stt','stt'),('music','music')]:
|
||||
subset=[p for p in rows if p['kind']==kind]
|
||||
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status().get('loaded')) if kind=='audio' else bool(self.stt and self.stt.status().get('loaded')) if kind=='stt' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None) or (kind=='stt' and self.stt is not None))
|
||||
counts[key]=dict(enabled=sum(p['enabled'] for p in subset),available=sum(p['enabled'] and p['runnable'] for p in subset),loaded=bool(worker['state']=='ready' and kind=='chat') if kind=='chat' else bool(self.tts and self.tts.status().get('loaded')) if kind=='audio' else bool(self.stt and self.stt.status().get('loaded')) if kind=='stt' else bool(self.music and self.music.status().get('loaded')) if kind=='music' else bool(kind=='image' and job and job['state']=='running'),supported=kind in ('chat','image') or (kind=='audio' and self.tts is not None) or (kind=='stt' and self.stt is not None) or (kind=='music' and self.music is not None))
|
||||
scheduler_status=self.scheduler.status()
|
||||
with self.lock:return dict(video=self.video.status() if self.video else None,state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
|
||||
# Video status can call back into endpoint.rows(); never hold our lock
|
||||
# while acquiring another service's lock. Concurrent GUI requests otherwise deadlock.
|
||||
video_status=self.video.status() if self.video else None
|
||||
with self.lock:return dict(video=video_status,state=self.state,reachable=bool(self.thread and self.thread.is_alive() and self.state=='running'),port=self.config['port'],bind=os.environ.get('DECK_API_BIND','127.0.0.1'),base_url=f"http://127.0.0.1:{self.config['port']}/v1",allowed_ports=self.allowed_ports,error=self.error,counts=counts,worker=worker,scheduler=scheduler_status,profiles=[dict(id=p['id'],name=p['name'],kind=p['kind'],enabled=p['enabled'],runnable=p['runnable'],blockers=p['blockers']) for p in rows],active_requests=self.inflight)
|
||||
def configure(self,data):
|
||||
if set(data)!={'port'} or type(data['port']) is not int or not 1024<=data['port']<=65535:raise ValueError('Port zwischen 1024 und 65535 erforderlich.')
|
||||
port=data['port']
|
||||
@@ -127,6 +130,7 @@ class Endpoint:
|
||||
# Process shutdown does not change the user's autostart preference.
|
||||
with self.lock:self.state='stopping';http=self.http
|
||||
if http:http.shutdown();http.server_close()
|
||||
if self.music:self.music.stop()
|
||||
if self.tts:self.tts.stop()
|
||||
if self.stt:self.stt.stop()
|
||||
self.images.stop();self.worker.stop()
|
||||
@@ -196,19 +200,20 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
with ep.lock:
|
||||
if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping')
|
||||
ep.inflight+=1;admitted=True
|
||||
if ep.video and ep.scheduler.gpu_mode!='llm':
|
||||
if ep.video and ep.scheduler.gpu_mode not in ('llm','music'):
|
||||
if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching')
|
||||
video=ep.video.status()
|
||||
if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready')
|
||||
try:ep.video.relay(self) if callable(getattr(type(ep.video),'relay',None)) else relay(self,video['selected'])
|
||||
except ValueError as exc:raise APIError(str(exc)) from None
|
||||
return
|
||||
model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt'}
|
||||
model_routes={'/v1/models':'chat','/v1/images/models':'image','/v1/audio/speech/models':'audio','/v1/audio/transcriptions/models':'stt','/v1/audio/music/models':'music'}
|
||||
if self.command=='GET' and self.path in model_routes:return self.send(ep.model_list(model_routes[self.path]))
|
||||
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
|
||||
if self.video_route(ep):return
|
||||
if self.command!='POST':raise APIError('Route nicht gefunden.',404)
|
||||
if ep.video and ep.scheduler.gpu_mode!='llm' and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active')
|
||||
if ep.scheduler.gpu_mode=='music' and self.path!='/v1/audio/music':raise APIError('Musik-Modus aktiv. Für andere KI-Aufträge auf LLM wechseln.',503,'music_mode_active')
|
||||
if ep.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active')
|
||||
if self.path=='/v1/audio/transcriptions':return self.transcription(ep)
|
||||
if self.path=='/v1/images/edits':
|
||||
from image_upload import read_image_upload
|
||||
@@ -219,7 +224,7 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
fields['n']=int(fields.get('n','1'))
|
||||
return self.image(ep,fields,images)
|
||||
except ValueError as exc:raise APIError(str(exc)) from None
|
||||
if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech'):raise APIError('Route nicht implementiert.',404)
|
||||
if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech','/v1/audio/music'):raise APIError('Route nicht implementiert.',404)
|
||||
if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
|
||||
try:length=int(self.headers.get('Content-Length','0'))
|
||||
except ValueError:raise APIError('Ungültige Content-Length.') from None
|
||||
@@ -228,6 +233,7 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
except (ValueError,UnicodeError):raise APIError('Ungültiges JSON.') from None
|
||||
if not isinstance(data,dict):raise APIError('JSON-Objekt erforderlich.')
|
||||
if self.path=='/v1/chat/completions':return self.chat(ep,data)
|
||||
if self.path=='/v1/audio/music':return self.music_generation(ep,data)
|
||||
if self.path=='/v1/audio/speech':return self.speech(ep,data)
|
||||
return self.image(ep,data)
|
||||
except (APIError,InferenceError) as exc:
|
||||
@@ -272,6 +278,21 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
except (ValueError,OSError):time.sleep(.25);continue
|
||||
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);return
|
||||
raise APIError('TTS-Zeitlimit überschritten.',504)
|
||||
def music_generation(self,ep,data):
|
||||
if ep.scheduler.gpu_mode!='music':raise APIError('Bitte in der Übersicht auf Musik umschalten.',503,'music_mode_required')
|
||||
if not ep.music:raise APIError('Musikworker nicht eingerichtet.',501)
|
||||
if set(data)-{'model','lyrics','style','seed','max_tokens','steps'}:raise APIError('Nicht unterstützte Musikfelder.')
|
||||
profile=ep.find_profile(data.get('model'),'music')
|
||||
try:job=ep.music.start(profile['id'],data.get('lyrics'),data.get('style'),seed=data.get('seed',1234),max_tokens=data.get('max_tokens',400),steps=data.get('steps',8),wait=True)
|
||||
except ValueError as exc:raise APIError(str(exc)) from None
|
||||
end=time.monotonic()+1850
|
||||
while time.monotonic()<end:
|
||||
current=ep.music.status()['job']
|
||||
if current and current['id']==job['id'] and current['state'] in ('failed','cancelled'):raise APIError(current['phase'],503)
|
||||
try:body=ep.music.audio(job['id'])
|
||||
except (ValueError,OSError):time.sleep(.5);continue
|
||||
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.end_headers();self.wfile.write(body);return
|
||||
ep.music.stop();raise APIError('Musik-Zeitlimit überschritten.',504)
|
||||
def chat(self,ep,data):
|
||||
try:data,self.compatibility=normalize_chat(data)
|
||||
except CompatibilityError as exc:raise APIError(str(exc)) from None
|
||||
@@ -327,7 +348,8 @@ class APIHandler(BaseHTTPRequestHandler):
|
||||
if data.get('n',1)!=1 or data.get('response_format','b64_json')!='b64_json':raise APIError('Unterstützt werden n=1 und response_format=b64_json.')
|
||||
profile=ep.find_profile(data.get('model'),'image');params=profile['parameters']
|
||||
from profiles import image_capabilities
|
||||
if len(reference_images or [])>image_capabilities(profile.get('model'))['reference_images']:raise APIError('Das aktive Bildprofil unterstützt diese Anzahl Referenzbilder nicht.')
|
||||
limit=image_capabilities(profile.get('model'))['reference_images']
|
||||
if len(reference_images or [])>limit:raise APIError(f"Bildprofil {profile['name']}: {len(reference_images or [])} Referenzbilder erhalten; die Deck-Laufzeitanbindung erlaubt maximal {limit}.")
|
||||
size=data.get('size')
|
||||
if size is not None and (not isinstance(size,str) or (size!='auto' and not re.fullmatch(r'[1-9][0-9]{1,4}x[1-9][0-9]{1,4}',size))):raise APIError('size muss auto oder eine Auflösung wie 1024x1024 sein.')
|
||||
# The selected profile owns resource limits; client size is only a preference.
|
||||
|
||||
+44
-4
@@ -1,10 +1,10 @@
|
||||
"""Read-only, conservative mapping to Deck's maintained execution adapters."""
|
||||
from catalog import file_role
|
||||
from stt import supported as stt_supported
|
||||
from profiles import image_recipe, video_recipe
|
||||
from profiles import image_recipe, image_adapter, video_recipe
|
||||
from tts_runtime import REPO, REVISION
|
||||
|
||||
def assess(model, installed, profile=None):
|
||||
def _assess(model, installed, profile=None):
|
||||
kind=model.get('kind'); filename=model.get('file',''); repo=model.get('repo','')
|
||||
meta=model.get('runtime_metadata') if isinstance(model.get('runtime_metadata'),dict) else {}
|
||||
architecture=meta.get('architecture') if isinstance(meta.get('architecture'),str) else None
|
||||
@@ -16,8 +16,8 @@ def assess(model, installed, profile=None):
|
||||
result['family']='Qwen3-ASR 0.6B'
|
||||
result.update(runtime='llama.cpp · Qwen3-ASR auf CPU',route='#stt-runtime',components=['Passender Audio-Projektor (unter Einrichten herunterladen)'])
|
||||
elif kind=='image' and image_recipe(model):
|
||||
result['family']='FLUX.2 Klein 9B' if 'FLUX' in filename.upper() else 'Qwen Image 2.1'
|
||||
result.update(runtime='ComfyUI + ComfyUI-GGUF',route='#image-runtime',components=[x['label'] for x in image_recipe(model).values()])
|
||||
result['family']=image_adapter(model)['label']
|
||||
result.update(runtime=image_adapter(model)['runtime'],route='#image-runtime',components=[x['label'] for x in image_recipe(model).values()])
|
||||
elif kind=='audio' and repo==REPO and filename=='model.safetensors' and model.get('revision')==REVISION:
|
||||
result['family']='Qwen3-TTS CustomVoice'
|
||||
result.update(runtime='Qwen3-TTS CustomVoice',route='#tts-runtime',components=['Vollständiges Modellpaket und Sprach-Tokenizer (im Einrichtungsassistenten enthalten)'])
|
||||
@@ -46,3 +46,43 @@ def assess(model, installed, profile=None):
|
||||
elif result['installed']:
|
||||
result.update(state='profile_incomplete',label='Profil prüfen – Voraussetzungen fehlen')
|
||||
return result
|
||||
|
||||
|
||||
def assess(model, installed, profile=None):
|
||||
result=_assess(model,installed,profile)
|
||||
result['candidates']=[]
|
||||
if result['state']=='component':return result
|
||||
kind=model.get('kind');repo=model.get('repo','').lower();name=model.get('file','').lower()
|
||||
meta=model.get('runtime_metadata') or {};family=str(meta.get('architecture') or '').lower()
|
||||
def add(id,label,key,route,reason,maintained=True):
|
||||
result['candidates'].append(dict(id=id,label=label,installed=bool(installed.get(key)),route=route,installation_available=maintained,evidence=reason))
|
||||
if result.get('runtime'):
|
||||
add('current',result['runtime'],kind,result['route'],'Vorhandene Deck-Zuordnung')
|
||||
if kind=='music' and repo=='audio-cpp/yue2-3b-gguf' and name.startswith('yue2-3b-') and name.endswith('.gguf'):
|
||||
result.update(family='YuE2',evidence='audio-cpp/Yue2-3B-GGUF · Hauptmodell',state='adapter_missing',label='YuE2 erkannt · Deck-Worker fehlt',message='Passende Laufzeit: audio.cpp. Sie kann einmalig heruntergeladen und gebaut werden. Zur Generierung fehlen noch der Deck-Musikworker und die Ladypoly-Anbindung. Installation allein schaltet dieses Modell nicht frei.',components=['YuE2-VAE GGUF','Modell-/Generierungskonfiguration, VAE-Konfiguration und Qwen-Tokenizer (sidecars)'])
|
||||
if installed.get('music_worker'):
|
||||
result.update(state='setup',label='YuE2-Musikworker verfügbar · Komponenten prüfen',message='audio.cpp-Laufzeit und benötigte Komponenten einrichten. Danach Musik → Testen verwenden oder Profil am Musik-API-Endpunkt freigeben.',runtime='audio.cpp',installed=bool(installed.get('audio_cpp')),route='#music')
|
||||
if profile:
|
||||
result.update(state='ready' if profile.get('runnable') else 'profile_incomplete',label='Ausführbar · audio.cpp-Musikworker' if profile.get('runnable') else 'Musikprofil einrichten',message='YuE2 wird mit den zugeordneten Komponenten über audio.cpp ausgeführt. Testen im Musikbereich; Ergebnisse als WAV. Ladypoly-WebUI ist separat.',blockers=profile.get('blockers',[]))
|
||||
add('audio_cpp','audio.cpp','audio_cpp','#audio-cpp-runtime','Geprüfte GGUF-Quelle für die YuE2-Modellfamilie')
|
||||
elif kind=='music' and ('yue2' in repo or 'yue2' in family):
|
||||
result.update(family='YuE2',evidence='Repository-/Architekturhinweis; Ausführung ungeprüft')
|
||||
add('yue2','YuE2 Python / PyTorch','yue2',None,'Originale YuE2-Pipeline; Originalgewichte erforderlich',False)
|
||||
if kind in ('audio','stt','music','voice') and not result['candidates']:
|
||||
if 'audio-cpp/' in repo and name.endswith('.gguf'):
|
||||
add('audio_cpp','audio.cpp','audio_cpp','#audio-cpp-runtime','audio-cpp-Repository; konkrete Familie und Zusatzdateien noch prüfen')
|
||||
elif meta.get('library_name')=='transformers':
|
||||
add('transformers','Hugging Face Transformers / PyTorch','transformers',None,'Hub nennt Transformers; modellabhängiger Worker erforderlich',False)
|
||||
if kind=='voice' and any(token in (repo+' '+name+' '+family) for token in ('roformer','demucs')):
|
||||
add('separator','Audio Separator','separator','#separator-runtime','Trennmodell-Hinweis; exakte Datei und Konfiguration müssen zum unterstützten Paket passen')
|
||||
result['candidate_message']='Unter Audio Separator sind geprüfte Modellpakete mit Konfiguration verfügbar. Beliebige CoreML-, ONNX- oder Safetensors-Abwandlungen sind nicht automatisch kompatibel.'
|
||||
if kind=='video' and not result['candidates'] and meta.get('library_name')=='diffusers':
|
||||
add('diffusers','Diffusers / PyTorch','diffusers',None,'Hub nennt Diffusers; passende Video-Pipeline noch prüfen',False)
|
||||
if not result['candidates']:
|
||||
# Category-level options are explicit research hints, never compatibility claims.
|
||||
options={'chat':('llama.cpp','chat','#runtime'), 'image':('ComfyUI','image','#image-runtime'), 'video':('ComfyUI','image','#image-runtime'), 'audio':('audio.cpp','audio_cpp','#audio-cpp-runtime'), 'stt':('audio.cpp','audio_cpp','#audio-cpp-runtime'), 'music':('audio.cpp','audio_cpp','#audio-cpp-runtime'), 'voice':('audio.cpp','audio_cpp','#audio-cpp-runtime')}
|
||||
if kind in options:
|
||||
label,key,route=options[kind]
|
||||
add('category',label,key,route,'Option für diese Kategorie; Kompatibilität mit dieser unbekannten Modellfamilie NICHT bestätigt')
|
||||
result['candidate_message']='Keine passende Laufzeit zuverlässig ermittelt. Modellkarte und Architektur müssen geprüft werden; keine automatische Installation auf Verdacht.'
|
||||
return result
|
||||
|
||||
+23
-8
@@ -13,7 +13,7 @@ import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
from profiles import QWEN_REPO, FLUX_REPO, image_recipe, image_capabilities
|
||||
from profiles import image_adapter, image_recipe, image_capabilities
|
||||
|
||||
PYTHON=Path('/opt/deck-image-python/bin/python')
|
||||
COMFY=Path('/opt/deck-comfy')
|
||||
@@ -46,9 +46,10 @@ def select_gpus(devices,model_size,encoder_size,vae_size,offload=False):
|
||||
return max(images,key=lambda g:g['free_mib']),encoders[0]
|
||||
|
||||
def workflow(prompt,params,seed,family='qwen',references=()):
|
||||
if references and family!='qwen':raise ValueError('Dieses Bildprofil unterstützt keine Referenzbilder.')
|
||||
if family not in ('qwen','flux'):raise ValueError('Für diese Modellfamilie fehlt eine Bildlaufzeit-Anbindung.')
|
||||
if len(references)>4:raise ValueError('Maximal vier Referenzbilder pro Auftrag.')
|
||||
if family=='flux':
|
||||
return {
|
||||
graph={
|
||||
'1':{'class_type':'UNETLoader','inputs':{'unet_name':'model.safetensors','weight_dtype':'default'}},
|
||||
'2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors','family':'flux2'}},
|
||||
'3':{'class_type':'VAELoader','inputs':{'vae_name':'vae.safetensors'}},
|
||||
@@ -64,6 +65,20 @@ def workflow(prompt,params,seed,family='qwen',references=()):
|
||||
'13':{'class_type':'ConditioningZeroOut','inputs':{'conditioning':['4',0]}}
|
||||
}
|
||||
|
||||
# FLUX.2 editing uses reference latents on both conditioning branches,
|
||||
# with fresh output noise; never silently discard uploaded references.
|
||||
positive=['4',0];negative=['13',0]
|
||||
for index,name in enumerate(references):
|
||||
load,scale,encode,pos,neg=map(str,range(20+index*5,25+index*5))
|
||||
graph[load]={'class_type':'LoadImage','inputs':{'image':name}}
|
||||
graph[scale]={'class_type':'ImageScaleToTotalPixels','inputs':{'image':[load,0],'upscale_method':'lanczos','megapixels':1.0,'resolution_steps':1}}
|
||||
graph[encode]={'class_type':'VAEEncode','inputs':{'pixels':[scale,0],'vae':['3',0]}}
|
||||
graph[pos]={'class_type':'ReferenceLatent','inputs':{'conditioning':positive,'latent':[encode,0]}}
|
||||
graph[neg]={'class_type':'ReferenceLatent','inputs':{'conditioning':negative,'latent':[encode,0]}}
|
||||
positive=[pos,0];negative=[neg,0]
|
||||
graph['10']['inputs'].update(positive=positive,negative=negative)
|
||||
return graph
|
||||
|
||||
graph={
|
||||
'1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}},
|
||||
'2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors'}},
|
||||
@@ -78,7 +93,7 @@ def workflow(prompt,params,seed,family='qwen',references=()):
|
||||
graph['4']['inputs']['vae']=['3',0]
|
||||
for index,name in enumerate(references,1):
|
||||
graph[str(20+index)]={'class_type':'LoadImage','inputs':{'image':name}}
|
||||
graph['4']['inputs'][f'image_{index}']=[str(20+index),0]
|
||||
graph['4']['inputs'][f'images.image_{index}']=[str(20+index),0]
|
||||
graph['6']['inputs']['latent_image']=['4',2]
|
||||
return graph
|
||||
|
||||
@@ -145,11 +160,11 @@ class ImageTests:
|
||||
encoder=self.profiles._component(model,'text_encoder',profile.get('components',{}).get('text_encoder'))
|
||||
vae=self.profiles._component(model,'vae',profile.get('components',{}).get('vae'))
|
||||
mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith(('MemAvailable:','MemTotal:'))}
|
||||
required_ram=max(20*GIB,encoder['size']*2+4*GIB,model['size']+8*GIB if model['repo']==FLUX_REPO else 0)
|
||||
required_ram=max(20*GIB,encoder['size']*2+4*GIB,model['size']+8*GIB if image_adapter(model)['offload'] else 0)
|
||||
headroom=cgroup_headroom()
|
||||
if headroom is not None and headroom<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
|
||||
if mem.get('MemAvailable',0)<required_ram+4*GIB:raise ValueError('Aktuell zu wenig freier System-RAM; produktive Dienste bleiben unverändert.')
|
||||
gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size'],offload=model['repo']==FLUX_REPO);self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size'],offload=image_adapter(model)['offload']);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'],encoder_gpu=encoder_gpu['name'],seed=seed,parameters=params,reference_count=len(references))
|
||||
@@ -166,7 +181,7 @@ class ImageTests:
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
self._phase('Prompt-Aufwerter entladen; Bildlaufzeit startet')
|
||||
directory.mkdir(mode=0o700)
|
||||
for role,item,filename in [('unet',model,'model.safetensors' if model['repo']==FLUX_REPO else 'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]:
|
||||
for role,item,filename in [('unet',model,image_adapter(model)['model_filename']),('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()
|
||||
names=[]
|
||||
@@ -203,7 +218,7 @@ class ImageTests:
|
||||
if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.')
|
||||
time.sleep(1)
|
||||
nodes=request('/object_info')
|
||||
graph=workflow(prompt,params,seed,'flux' if model['repo']==FLUX_REPO else 'qwen',names)
|
||||
graph=workflow(prompt,params,seed,image_adapter(model)['workflow'],names)
|
||||
required={node['class_type'] for node in graph.values()}
|
||||
if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Nodes für dieses Bildmodell.')
|
||||
self._phase('Auftrag wird verarbeitet · Textencoder auf RTX 3060 · Bildberechnung auf RTX 5080')
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/dashboard.css"><link rel="stylesheet" href="/themes.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/ltx-original-ui.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/dashboard-ui.js" defer></script><script src="/backup-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#dashboard"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#dashboard">Dashboard <span>01</span></a><a href="#home">Übersicht <span>02</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#backup">Backup & Restore <span>↗</span></a><a href="#appearance">Darstellung <span>↗</span></a><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#ltx-original-runtime">LTX Original</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/dashboard.css"><link rel="stylesheet" href="/themes.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/separator-ui.js" defer></script><script src="/music-ui.js" defer></script><script src="/audio-cpp-ui.js" defer></script><script src="/ltx-original-ui.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/dashboard-ui.js" defer></script><script src="/backup-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#dashboard"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><p class="nav-heading">Übersicht</p><a href="#dashboard">Dashboard</a><a href="#home">Steuerung</a><p class="nav-heading">KI-Werkzeuge</p><a href="#chat">Chat & Sprachmodelle</a><a href="#image">Bildgenerierung</a><a href="#music">Musikgenerierung</a><a href="#separation">Audio-Trennung</a><a href="#audio">Sprachausgabe (TTS)</a><a href="#stt">Spracherkennung (STT)</a><a href="#voice">Stimmwerkzeuge</a><a href="#video">Videogenerierung</a><p class="nav-heading">Anwendungen</p><a href="#services">Weitere Dienste</a><p class="nav-heading">Einstellungen</p><a href="#runtimes">Laufzeiten</a><a href="#endpoint">Endpunkt</a><a href="#network">Netzwerk</a><a href="#access">Zugang & API-Token</a><a href="#appearance">Darstellung</a><a href="#backup">Backup & Wiederherstellung</a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||
|
||||
+15
-8
@@ -23,14 +23,14 @@ class Scheduler:
|
||||
def status(self):
|
||||
with self.cv:return dict(active_requests=self.active+self.tts_active,waiting_requests=len(self.queue),switching=self.transition)
|
||||
@contextlib.contextmanager
|
||||
def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True):
|
||||
def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True, modes=("llm",)):
|
||||
ticket=object();deadline=time.monotonic()+timeout;claimed=False
|
||||
with self.cv:
|
||||
if len(self.queue)>=16:raise InferenceError('Warteschlange voll. Bitte später erneut versuchen.')
|
||||
self.queue.append(ticket)
|
||||
try:
|
||||
while True:
|
||||
if self.gpu_mode!="llm":raise InferenceError("Video-Modus aktiv oder Moduswechsel läuft; GPU-Aufträge sind gesperrt.")
|
||||
if self.gpu_mode not in modes:raise InferenceError("Video-Modus aktiv oder Moduswechsel läuft; GPU-Aufträge sind gesperrt.")
|
||||
if not allowed():raise InferenceError('Endpunkt wird gestoppt oder Profil ist nicht mehr aktiviert.')
|
||||
if self.queue[0] is ticket and not self.transition and (not self.active or (self.key==key and self.active<slots)) and (not self.tts_active or self.key==key and key!=('image',)):
|
||||
self.queue.pop(0);self.active+=1;claimed=True
|
||||
@@ -47,7 +47,7 @@ class Scheduler:
|
||||
if self.transition:
|
||||
try:
|
||||
if switch:self.worker.stop()
|
||||
if key==('image',) or switch:self.evict_tts()
|
||||
if key in (('image',),('separator',)) or switch:self.evict_tts()
|
||||
if prepare:prepare()
|
||||
except Exception:
|
||||
self.worker.stop()
|
||||
@@ -60,7 +60,7 @@ class Scheduler:
|
||||
if claimed:
|
||||
with self.cv:self.active-=1;self.cv.notify_all()
|
||||
def image_reservation(self, wait=False,kind='image'):
|
||||
lease=self.lease((kind,),timeout=600 if wait else 0)
|
||||
lease=self.lease((kind,),timeout=600 if wait else 0,modes=("music",) if kind=="music" else ("llm",))
|
||||
lease.__enter__()
|
||||
return lambda:lease.__exit__(None,None,None)
|
||||
def tts_reservation(self,wait=False):
|
||||
@@ -158,6 +158,7 @@ class LlamaWorker:
|
||||
if params['split_mode']=='none':selected=selected[:1]
|
||||
if not selected or any(g['processes'] or g['free_mib']<2048 for g in selected):raise InferenceError('Benötigte GPU ist durch einen anderen Dienst belegt. Deck stoppt keine fremden Prozesse.')
|
||||
mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('MemAvailable:')}
|
||||
model_devices=list(selected)
|
||||
projector=None;vision_args=[];vision_bytes=0;vision_device=params.get('vision_device','cpu')
|
||||
if params.get('vision_projector'):
|
||||
projector=self.catalog.entry(params['vision_projector'])
|
||||
@@ -166,7 +167,11 @@ class LlamaWorker:
|
||||
vision_args=['--mmproj',str((self.catalog.root/projector['id']/'model.gguf').resolve())]
|
||||
if vision_device=='cpu':vision_args+=['--no-mmproj-offload']
|
||||
else:
|
||||
if vision_device not in [g['uuid'] for g in selected]:raise InferenceError('Projektor-GPU ist nicht ausgewählt.')
|
||||
if vision_device not in [g['uuid'] for g in selected]:
|
||||
target=next((g for g in devices if g['uuid']==vision_device),None)
|
||||
if target is None:raise InferenceError('Projektor-GPU ist nicht verfügbar.')
|
||||
if target['processes'] or target['free_mib']<2048:raise InferenceError('Projektor-GPU ist durch einen anderen Dienst belegt.')
|
||||
selected.append(target)
|
||||
vision_args+=['--mmproj-device','CUDA'+str([g['uuid'] for g in selected].index(vision_device)),'--mmproj-offload']
|
||||
staging=entry['size']+4*GIB+vision_bytes
|
||||
if mem.get('MemAvailable',0)<staging+4*GIB:raise InferenceError('Zu wenig freier RAM für Modellladen und Host-Reserve.')
|
||||
@@ -177,7 +182,8 @@ class LlamaWorker:
|
||||
env.update(CUDA_VISIBLE_DEVICES=','.join(g['uuid'] for g in selected),HF_HUB_OFFLINE='1',OMP_NUM_THREADS=str(params['threads']))
|
||||
reserve_mode=params.get('gpu_reserve_mode','auto')
|
||||
margins=[0 if reserve_mode=='none' else params.get('gpu_reserve_mib',{}).get(g['uuid'],512) if reserve_mode=='manual' else max(512,round(g['total_mib']*.025)) for g in selected]
|
||||
common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k',params.get('cache_type_k','q4_0'),'--cache-type-v',params.get('cache_type_v','q4_0'),'--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(map(str,margins))]
|
||||
common=['--model',str(model),'--ctx-size',str(params['context']),'--parallel',str(params['slots']),'--batch-size',str(params['batch']),'--ubatch-size',str(params['ubatch']),'--cache-type-k',params.get('cache_type_k','q4_0'),'--cache-type-v',params.get('cache_type_v','q4_0'),'--flash-attn','on','--split-mode',params['split_mode'],'--fit-target',','.join(map(str,margins[:len(model_devices)]))]
|
||||
common+=['--device',','.join('CUDA'+str(i) for i in range(len(model_devices)))]
|
||||
if params['tensor_split']:common+=['--tensor-split',','.join(map(str,params['tensor_split']))]
|
||||
tool=str(directory/'build/bin/llama-fit-params')
|
||||
mtp_fit=['--deck-mtp'] if mtp else []
|
||||
@@ -188,7 +194,8 @@ class LlamaWorker:
|
||||
for line in output.splitlines():
|
||||
parts=line.split()
|
||||
if len(parts)==4 and all(x.isdigit() for x in parts[1:]):rows[parts[0]]=sum(map(int,parts[1:]))
|
||||
if 'Host' not in rows or any('CUDA'+str(i) not in rows for i in range(len(selected))):raise InferenceError('Speicherprognose unvollständig; Start abgebrochen.')
|
||||
if 'Host' not in rows or any('CUDA'+str(i) not in rows for i in range(len(model_devices))):raise InferenceError('Speicherprognose unvollständig; Start abgebrochen.')
|
||||
for i in range(len(model_devices),len(selected)):rows.setdefault('CUDA'+str(i),0)
|
||||
if projector:
|
||||
target='Host' if vision_device=='cpu' else 'CUDA'+str([g['uuid'] for g in selected].index(vision_device))
|
||||
rows[target]+=round(vision_bytes/1024**2)
|
||||
@@ -226,7 +233,7 @@ class LlamaWorker:
|
||||
layers=int(fit['-ngl'])
|
||||
if '-ts' in fit:
|
||||
parts=fit['-ts'].split(',')
|
||||
if len(parts)!=len(selected) or any(not x.replace('.','',1).isdigit() for x in parts):raise InferenceError('Ungültige automatische GPU-Verteilung.')
|
||||
if len(parts)!=len(model_devices) or any(not x.replace('.','',1).isdigit() for x in parts):raise InferenceError('Ungültige automatische GPU-Verteilung.')
|
||||
extra=['--tensor-split',fit['-ts']]
|
||||
fits,memory=estimate(layers,extra)
|
||||
if not fits:raise InferenceError('Speicherprognose überschreitet die GPU-/RAM-Reserve.')
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
async function copyTokenValue(value){
|
||||
if(!value)throw Error('empty');
|
||||
try{if(navigator.clipboard?.writeText){await navigator.clipboard.writeText(value);return;}}catch{}
|
||||
const active=document.activeElement,selection=window.getSelection(),ranges=[];
|
||||
if(selection)for(let i=0;i<selection.rangeCount;i++)ranges.push(selection.getRangeAt(i).cloneRange());
|
||||
const area=document.createElement('textarea');area.value=value;area.readOnly=true;
|
||||
area.style.cssText='position:fixed;left:-9999px;top:0;opacity:0';document.body.append(area);
|
||||
try{area.focus();area.select();if(!document.execCommand('copy'))throw Error('clipboard');}
|
||||
finally{area.value='';area.remove();active?.focus();if(selection){selection.removeAllRanges();for(const range of ranges)selection.addRange(range);}}
|
||||
}
|
||||
let firstSetup=false;
|
||||
const form=document.querySelector('#login'),message=document.querySelector('#error');
|
||||
function freshToken(){const bytes=crypto.getRandomValues(new Uint8Array(32));return 'ad_'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
|
||||
async function load(){try{const r=await fetch('/api/v1/auth/status'),s=await r.json();firstSetup=!s.initialized;document.querySelector('#title').textContent=firstSetup?'Dein Zugang zu Athena.':'Willkommen zurück.';document.querySelector('#intro').textContent=firstSetup?'Lege das Kennwort für diese Oberfläche und einen separaten Token für API-Clients fest.':'Melde dich mit deinem Oberflächenkennwort an.';document.querySelector('#setup-fields').hidden=!firstSetup;document.querySelector('#password').autocomplete=firstSetup?'new-password':'current-password';document.querySelector('#submit').textContent=firstSetup?'Zugang einrichten':'Anmelden';form.hidden=false;}catch(e){message.textContent='Zugangsstatus nicht erreichbar.'}}
|
||||
document.querySelector('#generate').onclick=()=>{document.querySelector('#api-token').value=freshToken();message.textContent='Token erzeugt. Vor dem Einrichten kopieren und sicher speichern.';};
|
||||
const syncSetupCopy=()=>document.querySelector('#copy').disabled=!document.querySelector('#api-token').value;
|
||||
document.querySelector('#api-token').addEventListener('input',syncSetupCopy);syncSetupCopy();
|
||||
document.querySelector('#generate').onclick=()=>{document.querySelector('#api-token').value=freshToken();syncSetupCopy();message.textContent='Token erzeugt. Vor dem Einrichten kopieren und sicher speichern.';};
|
||||
document.querySelector('#show-token').onclick=()=>{const input=document.querySelector('#api-token');input.type=input.type==='password'?'text':'password';document.querySelector('#show-token').textContent=input.type==='password'?'Token anzeigen':'Token verbergen';};
|
||||
document.querySelector('#copy').onclick=async()=>{try{const token=document.querySelector('#api-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);message.textContent='Token kopiert. Bitte sicher speichern.';}catch{message.textContent='Token konnte nicht kopiert werden. Bitte einen eigenen Token aus deinem Passwortmanager einfügen.'}};
|
||||
form.addEventListener('submit',async event=>{event.preventDefault();const input=document.querySelector('#password'),button=document.querySelector('#submit');button.disabled=true;try{let data={password:input.value},path='login';if(firstSetup){if(input.value!==document.querySelector('#repeat').value)throw Error('Die Kennwörter stimmen nicht überein.');data.api_token=document.querySelector('#api-token').value;path='auth/setup';}const r=await fetch('/api/v1/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const value=await r.json();if(!r.ok)throw Error(value.error);form.reset();if(firstSetup){await load();message.textContent='Zugang eingerichtet. Bitte jetzt anmelden.';}else location.reload();}catch(e){message.textContent=e.message}finally{button.disabled=false}});
|
||||
document.querySelector('#copy').onclick=async()=>{try{const token=document.querySelector('#api-token').value;if(!token)throw Error();await copyTokenValue(token);message.textContent='Token kopiert. Bitte sicher speichern.';}catch{message.textContent='Der Browser hat das Kopieren nicht erlaubt. Bitte „Token anzeigen“ wählen und manuell kopieren.'}};
|
||||
form.addEventListener('submit',async event=>{event.preventDefault();const input=document.querySelector('#password'),button=document.querySelector('#submit');button.disabled=true;try{let data={password:input.value},path='login';if(firstSetup){if(input.value!==document.querySelector('#repeat').value)throw Error('Die Kennwörter stimmen nicht überein.');data.api_token=document.querySelector('#api-token').value;path='auth/setup';}const r=await fetch('/api/v1/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const value=await r.json();if(!r.ok)throw Error(value.error);form.reset();syncSetupCopy();if(firstSetup){await load();message.textContent='Zugang eingerichtet. Bitte jetzt anmelden.';}else location.reload();}catch(e){message.textContent=e.message}finally{button.disabled=false}});
|
||||
load();
|
||||
|
||||
@@ -14,6 +14,7 @@ DESKTOP_REV='68cd86c15e5fd25f56229ea63c0dbcb0338f7812'
|
||||
LTX_REV='d151147788a9284cca791edc6ce898007e727fe6'
|
||||
|
||||
class LTXOriginalRuntime:
|
||||
name="LTX Original"
|
||||
def __init__(self,root,image_runtime):
|
||||
self.root=Path(root);self.image_runtime=image_runtime;self.lock=threading.RLock();self.cancel=threading.Event();self.process=None;self.worker=None;self.job=None
|
||||
path=self.root/'installation.json'
|
||||
@@ -37,8 +38,8 @@ class LTXOriginalRuntime:
|
||||
with self.lock:self.job['phase']=phase;self._save()
|
||||
def start(self):
|
||||
with self.lock:
|
||||
if self.job and self.job['state']=='running':raise ValueError('LTX-Installation läuft bereits.')
|
||||
if self.status()['installed']:raise ValueError('LTX Original ist bereits installiert.')
|
||||
if self.job and self.job['state']=='running':raise ValueError(self.name+'-Installation läuft bereits.')
|
||||
if self.status()['installed']:raise ValueError(self.name+' ist bereits installiert.')
|
||||
if sys.platform!='linux' or not shutil.which('git'):raise ValueError('Debian/Linux mit Git und Python venv erforderlich.')
|
||||
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
need=self.status()['required_disk_gib']
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
window.MusicUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const x=await r.json();if(!r.ok)throw Error(x.error||'Musikanfrage fehlgeschlagen');return x;}
|
||||
function html(){return '<section id="music-test" class="card"><h2>Musik ausprobieren</h2><p>YuE2 über audio.cpp. Englische Songtexte werden empfohlen. Ein kurzer Test beginnt mit höchstens 400 semantischen Tokens; dies ist keine feste Dauer in Sekunden.</p><p id="music-state" role="status"></p><p id="music-error" role="alert"></p><div id="music-progress"></div><form id="music-form"><label>Profil<select id="music-profile" required></select></label><label>Musikstil<textarea id="music-style" maxlength="2000" required placeholder="English, acoustic pop, warm vocals, gentle guitar"></textarea></label><label>Songtext<textarea id="music-lyrics" maxlength="8000" rows="6" required placeholder="[Verse] … [Chorus] …"></textarea></label><details><summary>Testparameter</summary><label>Maximale semantische Tokens<input type="number" id="music-tokens" min="32" max="9000" value="400"></label><label>NAR-Schritte<input type="number" id="music-steps" min="1" max="32" value="8"></label><label>Seed<input type="number" id="music-seed" min="0" max="2147483647" value="1234"></label></details><button id="music-start">Musik erzeugen</button><button type="button" class="secondary" id="music-cancel">Abbrechen</button></form><div id="music-result"></div><p>RTX 5080 bevorzugt. Deck-Aufträge werden koordiniert. Nach der Anfrage wird das Modell entladen. Text und Stil werden nicht dauerhaft gespeichert.</p></section>';}
|
||||
function bind(){const root=document.querySelector('#music-test'),el=id=>root.querySelector('#'+id);let ready=false,busy=false;
|
||||
async function poll(){if(!root.isConnected)return;try{const s=await api('music');if(!root.isConnected)return;const j=s.job,running=j?.state==='running';el('music-state').textContent=(s.mode!=='music'?'Bitte in der Übersicht auf Musik umschalten.':j?.phase||'Profil wählen und Musikstil sowie Songtext eingeben.')+(running?' · '+Math.round((Date.now()/1000)-j.started_at)+' s':'');el('music-progress').innerHTML=running?'<progress aria-label="Musikgenerierung läuft"></progress>':'';el('music-start').disabled=busy||running||!ready||s.mode!=='music';el('music-cancel').disabled=!running;if(j?.state==='complete')el('music-result').innerHTML=`<h3>Ergebnis · ${e(j.duration_seconds)} Sekunden</h3><audio controls src="/api/v1/music/audio?id=${e(j.id)}"></audio><p><a download="athena-musik.wav" href="/api/v1/music/audio?id=${e(j.id)}">WAV herunterladen</a></p>`;if(j?.state==='failed')el('music-error').textContent=j.phase;}catch(x){if(root.isConnected)el('music-error').textContent=x.message;}if(root.isConnected)setTimeout(poll,1500);}
|
||||
api('profiles').then(s=>{if(!root.isConnected)return;const profiles=s.profiles.filter(p=>p.kind==='music'&&p.runnable);ready=profiles.length>0;el('music-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst Profil und Komponenten einrichten</option>';}).catch(x=>el('music-error').textContent=x.message);
|
||||
el('music-form').onsubmit=async event=>{event.preventDefault();busy=true;el('music-start').disabled=true;el('music-error').textContent='';el('music-result').replaceChildren();try{await api('music/start',{profile_id:el('music-profile').value,lyrics:el('music-lyrics').value,style:el('music-style').value,seed:Number(el('music-seed').value),max_tokens:Number(el('music-tokens').value),steps:Number(el('music-steps').value)});}catch(x){el('music-error').textContent=x.message;}finally{busy=false;}};el('music-cancel').onclick=()=>api('music/cancel',{}).catch(x=>el('music-error').textContent=x.message);poll();}
|
||||
return {html,bind};
|
||||
})();
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Owned audio.cpp music jobs, coordinated GPU lease, no persistent prompts."""
|
||||
import os,signal,subprocess,threading,time,uuid,wave
|
||||
from pathlib import Path
|
||||
from profiles import music_recipe
|
||||
from image_test import probe
|
||||
class Music:
|
||||
def __init__(self,root,profiles,catalog,runtime,scheduler):
|
||||
self.root=Path(root);self.profiles=profiles;self.catalog=catalog;self.runtime=runtime;self.scheduler=scheduler;self.lock=threading.RLock();self.job=None;self.process=None;self.cancel=threading.Event()
|
||||
def blockers(self,p):
|
||||
if not music_recipe(p['model']):return ['Für diese Musik-Modellfamilie fehlt noch eine geprüfte audio.cpp-Anbindung.']
|
||||
result=[] if self.runtime.status()['installed'] else ['audio.cpp zuerst unter Laufzeiten installieren.']
|
||||
for role,info in music_recipe(p['model']).items():
|
||||
try:self.profiles._component(p['model'],role,p.get('components',{}).get(role))
|
||||
except ValueError:result.append(info['label']+' fehlt oder ist nicht zugeordnet.')
|
||||
return result
|
||||
def status(self):
|
||||
with self.lock:return dict(job=dict(self.job) if self.job else None,loaded=bool(self.process and self.process.poll() is None),mode=self.scheduler.gpu_mode,runtime_installed=self.runtime.status()['installed'])
|
||||
def start(self,profile_id,lyrics,style,seed=1234,max_tokens=400,steps=8,wait=False):
|
||||
if not isinstance(lyrics,str) or not 1<=len(lyrics.strip())<=8000:raise ValueError('Songtext mit 1–8000 Zeichen eingeben.')
|
||||
if not isinstance(style,str) or not 1<=len(style.strip())<=2000:raise ValueError('Musikstil mit 1–2000 Zeichen eingeben.')
|
||||
for value,lo,hi in ((seed,0,2**31-1),(max_tokens,32,9000),(steps,1,32)):
|
||||
if type(value) is not int or not lo<=value<=hi:raise ValueError('Ungültige Musikparameter.')
|
||||
if self.scheduler.gpu_mode!='music':raise ValueError('Bitte in der Übersicht auf Musik umschalten.')
|
||||
with self.lock:
|
||||
if self.job and self.job['state']=='running':raise ValueError('Musikauftrag läuft bereits.')
|
||||
p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='music'),None)
|
||||
if not p or not p['runnable']:raise ValueError('Musikprofil noch nicht vollständig eingerichtet.')
|
||||
self.cancel.clear();self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf Deck-GPU-Freigabe',profile_id=profile_id,started_at=time.time(),max_tokens=max_tokens)
|
||||
threading.Thread(target=self._run,args=(p,lyrics,style,seed,max_tokens,steps,wait),daemon=True).start();return dict(self.job)
|
||||
def _run(self,p,lyrics,style,seed,max_tokens,steps,wait):
|
||||
release=None;state='failed';phase='Musikgenerierung fehlgeschlagen.';directory=self.root/self.job['id'];log=directory/'worker.tmp'
|
||||
try:
|
||||
release=self.scheduler.image_reservation(wait=wait,kind="music")
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
required=int(p['model']['size']/1024**2)+1800
|
||||
gpu=next((g for g in probe() if 'RTX 5080' in g['name'] and not g['processes'] and g['free_mib']>required),None)
|
||||
if not gpu:raise ValueError('RTX 5080 nicht ausreichend frei. Fremde GPU-Prozesse werden nicht beendet.')
|
||||
directory.mkdir(mode=0o700);bundle=directory/'package';bundle.mkdir();(bundle/'sidecars').mkdir()
|
||||
def link(entry,name):
|
||||
source=self.catalog.root/entry['id']/('model'+Path(entry['file']).suffix)
|
||||
(bundle/name).symlink_to(source.resolve())
|
||||
link(p['model'],p['model']['file'])
|
||||
for role in music_recipe(p['model']):
|
||||
entry=self.profiles._component(p['model'],role,p['components'][role]);link(entry,entry['file'])
|
||||
vae=self.catalog.entry(p['components']['vae'])['file'];cli=self.runtime.paths()[0]
|
||||
args=[str(cli),'--task','gen','--family','yue2','--model',str(bundle),'--backend','cuda','--device','0','--threads','6','--session-option','yue2.model_gguf='+p['model']['file'],'--session-option','yue2.vae_gguf='+vae,'--lyrics',lyrics,'--request-option','style='+style,'--request-option','cot=off','--request-option','semantic_min_tokens=0','--request-option','semantic_max_tokens='+str(max_tokens),'--request-option','num_inference_steps='+str(steps),'--seed',str(seed),'--out',str(directory/'result.wav'),'--log']
|
||||
with self.lock:self.job.update(phase='YuE2 wird auf der RTX 5080 geladen · anschließend Musik erzeugen',gpu=gpu['name'])
|
||||
with log.open('w') as out:
|
||||
with self.lock:
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
self.process=subprocess.Popen(args,stdout=out,stderr=subprocess.STDOUT,start_new_session=True,env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid'],OMP_NUM_THREADS='6'))
|
||||
deadline=time.monotonic()+1800
|
||||
while self.process.poll() is None:
|
||||
if self.cancel.wait(.5):raise InterruptedError()
|
||||
if time.monotonic()>deadline:raise ValueError('Zeitlimit von 30 Minuten erreicht.')
|
||||
with self.lock:self.job['elapsed_seconds']=round(time.time()-self.job['started_at'])
|
||||
if self.process.returncode:
|
||||
output=log.read_text(errors='replace').lower()
|
||||
if 'out of memory' in output or 'cuda error 2' in output:raise ValueError('GPU-Speicher reicht nicht (Out of Memory). Kleinere Quantisierung oder kürzeren Test wählen.')
|
||||
raise ValueError('audio.cpp hat die Generierung abgebrochen. Modellpaket oder Laufzeit prüfen.')
|
||||
with wave.open(str(directory/'result.wav'),'rb') as wav:
|
||||
if wav.getnframes()<1 or wav.getnchannels() not in (1,2):raise ValueError('Ungültige WAV-Ausgabe.')
|
||||
duration=wav.getnframes()/wav.getframerate()
|
||||
(directory/'complete').touch();state='complete';phase='Musik fertig · Modell entladen'
|
||||
with self.lock:self.job['duration_seconds']=round(duration,2)
|
||||
except InterruptedError:state='cancelled';phase='Musikgenerierung abgebrochen · Modell entladen'
|
||||
except Exception as exc:phase=str(exc) if isinstance(exc,ValueError) else 'Musikgenerierung fehlgeschlagen. Modellpaket und Laufzeit prüfen.'
|
||||
finally:
|
||||
process=self.process
|
||||
if process and process.poll() is None:
|
||||
try:os.killpg(process.pid,signal.SIGTERM);process.wait(5)
|
||||
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
|
||||
except ProcessLookupError:pass
|
||||
log.unlink(missing_ok=True)
|
||||
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time())
|
||||
if release:release()
|
||||
def stop(self):
|
||||
self.cancel.set();return {'cancellation_requested':True}
|
||||
def audio(self,ident):
|
||||
if not isinstance(ident,str) or len(ident)!=32 or any(c not in '0123456789abcdef' for c in ident):raise ValueError('Ungültige Auftrag-ID.')
|
||||
directory=self.root/ident
|
||||
if not (directory/'complete').is_file():raise ValueError('Musik noch nicht fertig.')
|
||||
wav=directory/'result.wav'
|
||||
if wav.stat().st_size>256*1024**2:raise ValueError('Audioausgabe zu groß.')
|
||||
return wav.read_bytes()
|
||||
+13
-11
@@ -7,9 +7,10 @@ window.ProfilesUI=(()=>{
|
||||
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],entries=[],panelSequence=0;
|
||||
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
|
||||
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
|
||||
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${['image','video'].includes(kind)?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
||||
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
|
||||
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
|
||||
const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId));
|
||||
el('profile-list').innerHTML=rows.map(p=>`<details class="detail-row" data-row-id="${e(p.id)}" ${openRows.has(p.id)?'open':''}><summary class="detail-summary"><strong>${e(p.name)}</strong><span class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</span><span>${p.parameters.context?`${e(p.parameters.context)} Token · ${e(p.parameters.slots||1)} Slots`:e(p.execution?.runtime||'Laufzeit prüfen')}</span><span>${enabled.includes(p.id)?'API: freigegeben':'API: nicht freigegeben'}<small>${p.runnable?'Bereit · lädt bei Anfrage':'Einrichtung fehlt'}</small></span></summary><div class="detail-body"><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Steuerung aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button><button class="secondary" data-components="${p.id}">Einrichtung & Komponenten</button>${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></div></details>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
|
||||
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Steuerung lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':kind==='music'?'Gib ein eingerichtetes Musikprofil frei, um es in der Steuerung verwenden zu können.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
|
||||
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Steuerung lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
|
||||
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
|
||||
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
|
||||
root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers)));
|
||||
@@ -31,28 +32,29 @@ window.ProfilesUI=(()=>{
|
||||
}
|
||||
refresh();el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}
|
||||
async function components(profile){
|
||||
async function components(profile,extraRepo){
|
||||
const sequence=++panelSequence;
|
||||
el('profile-editor').innerHTML='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>';
|
||||
try{
|
||||
const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id}));if(!root.isConnected||sequence!==panelSequence)return;
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><div class="card-top"><h2>Komponenten · ${e(profile.name)}</h2><button class="secondary" id="components-close">Schließen</button></div><p>${e(data.message)}</p>${data.supported?`<p>Quelle: <a class="link" href="${e(data.source)}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> · ${e(data.license||'Lizenz nicht angegeben')}</p><p>${profile.kind==='image'?'<a class="link" href="#image-runtime">Bildlaufzeit →</a> · ':'Videolaufzeit · '}${e(data.runtime)} · ${data.runtime_installed?'installiert':'noch nicht eingerichtet'}</p><button type="button" id="components-download-all">Alle fehlenden Dateien herunterladen</button><form id="components-form">${data.requirements.map(r=>`<section class="card component-card"><h3>${e(r.label)}</h3><label>Datei aus der Bibliothek<select name="${r.role}"><option value="">Noch nicht zugeordnet</option>${r.available.map(f=>`<option value="${f.id}" ${(profile.components?.[r.role]===f.id||(!profile.components?.[r.role]&&r.available.length===1))?'selected':''}>${e(f.file)} · ${(f.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${r.candidates.map((f,i)=>`<div class="note"><p class="hub-filename">${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}</p>${f.gated&&!f.auth_configured?'<p>Hugging-Face-Zugang unter <a href="#access">Einstellungen → Zugang</a> hinterlegen und Modellfreigabe prüfen.</p>':''}<button type="button" class="secondary" data-component-download="${r.role}:${i}" ${(f.gated&&!f.auth_configured)||r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'disabled':''}>${r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'Bereits in Bibliothek':'Datei herunterladen'}</button></div>`).join('')||'<p>Keine passende Quelldatei verfügbar.</p>'}</section>`).join('')}<div class="card-actions"><button id="components-save">Zuordnung auf Athena speichern</button><button type="button" class="secondary" id="components-refresh">Bibliothek aktualisieren</button></div><p class="small">Lade je eine Datei pro benötigter Komponente. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.</p></form>`:''}<p id="components-message" role="status"></p></section>`;
|
||||
const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id,...(extraRepo?{repo:extraRepo}:{})}));if(!root.isConnected||sequence!==panelSequence)return;
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><div class="card-top"><h2>Komponenten · ${e(profile.name)}</h2><button class="secondary" id="components-close">Schließen</button></div><p>${e(data.message)}</p><details><summary>Andere Komponentenquelle auswählen (ungeprüft)</summary><label>Hugging-Face-Link oder Repository<input id="component-source" placeholder="https://huggingface.co/Organisation/Modell"></label><button id="component-source-open" type="button">Dateien anzeigen</button></details>${data.supported?`<p>Quelle: <a class="link" href="${e(data.source)}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> · ${e(data.license||'Lizenz nicht angegeben')}</p><p>Laufzeit: ${e(data.runtime)} · ${data.manual?'Zuordnung ungeprüft':data.runtime_installed?'installiert':'noch nicht eingerichtet'}</p>${!data.manual?'<button type="button" id="components-download-all">Alle fehlenden Dateien herunterladen</button>':'<p>Nur benötigte Dateien einzeln auswählen; diese Liste ist kein automatischer Downloadplan.</p>'}<form id="components-form">${data.requirements.map(r=>`<section class="card component-card"><h3>${e(r.label)}</h3><label>Datei aus der Bibliothek<select name="${r.role}"><option value="">Noch nicht zugeordnet</option>${r.available.map(f=>`<option value="${f.id}" ${(profile.components?.[r.role]===f.id||(!data.manual&&!profile.components?.[r.role]&&r.available.length===1))?'selected':''}>${e(f.file)} · ${(f.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${r.candidates.map((f,i)=>`<div class="note"><p data-download-feedback="${r.role}:${i}" role="status"></p><p class="hub-filename">${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}</p>${f.gated&&!f.auth_configured?'<p>Hugging-Face-Zugang unter <a href="#access">Einstellungen → Zugang</a> hinterlegen und Modellfreigabe prüfen.</p>':''}<button type="button" class="secondary" data-component-download="${r.role}:${i}" ${(f.gated&&!f.auth_configured)||r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'disabled':''}>${r.available.some(x=>x.file===f.name&&x.revision===f.revision)?'Bereits in Bibliothek':'Datei herunterladen'}</button></div>`).join('')||'<p>Keine passende Quelldatei verfügbar.</p>'}</section>`).join('')}<div class="card-actions"><button id="components-save">Zuordnung auf Athena speichern</button><button type="button" class="secondary" id="components-refresh">Bibliothek aktualisieren</button></div><p class="small">Lade je eine Datei pro benötigter Komponente. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.</p></form>`:''}<p id="components-message" role="status"></p></section>`;
|
||||
const missing=data.requirements?.filter(r=>!r.available.length)||[];
|
||||
if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{
|
||||
const b=el('components-download-all');b.disabled=true;let count=0;
|
||||
try{for(const r of missing){const f=r.candidates.find(f=>!f.gated||f.auth_configured);if(!f)throw Error('Keine herunterladbare Datei; HF-Zugang oder Quellversion prüfen für '+r.label);const catalog=await api('catalog');if((catalog.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===f.repo&&j.file===f.name&&j.revision===f.revision))continue;await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});count++;}if(el('components-message'))el('components-message').textContent='Dateien sind im Downloadplan. Nach Abschluss Bibliothek aktualisieren und Zuordnung speichern; passende Dateien werden vorausgewählt.';}
|
||||
catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}
|
||||
};}
|
||||
el('component-source-open').onclick=()=>{const value=el('component-source').value.trim();if(value)components(profile,value);};
|
||||
el('components-close').onclick=()=>el('profile-editor').replaceChildren();
|
||||
el('components-refresh')?.addEventListener('click',()=>components(profile));
|
||||
el('components-refresh')?.addEventListener('click',()=>components(profile,extraRepo));
|
||||
el('components-form')?.addEventListener('submit',async event=>{event.preventDefault();const button=el('components-save');button.disabled=true;try{await api('profiles/components',{id:profile.id,revision:profile.revision,components:Object.fromEntries(new FormData(event.target))});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Komponenten dem Profil zugeordnet. Kein Modell gestartet.');}catch(error){if(el('components-message'))el('components-message').textContent=error.message;}finally{button.disabled=false;}});
|
||||
root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
|
||||
root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const feedback=b.parentElement.querySelector('[data-download-feedback]');feedback.textContent='Download wird angefragt …';const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});feedback.textContent='In Download-Warteschlange aufgenommen.';if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){feedback.textContent=error.message;feedback.className='warning';if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
|
||||
}catch(error){message(error.message);el('profile-editor').innerHTML='<section class="card"><p>Komponenten konnten nicht geladen werden. Öffne die Ansicht erneut.</p></section>';}
|
||||
}
|
||||
function gpuEditor(params){
|
||||
const selected=params.gpu_devices||[],available=[...gpus];for(const uuid of selected)if(!available.some(g=>g.uuid===uuid))available.push({uuid,name:'Derzeit nicht verfügbar'});
|
||||
const options=(value,empty)=>`<option value="">${empty}</option>`+available.map(g=>`<option value="${e(g.uuid)}" ${value===g.uuid?'selected':''}>${e(g.name)} · ${e(g.uuid)}</option>`).join('');
|
||||
return `<h3>GPU-Auswahl und Verteilung</h3><div class="form-grid"><label>GPU-Ausführung<select name="gpu_offload"><option value="auto" ${(params.gpu_offload||'auto')==='auto'?'selected':''}>Automatisch · CPU-Auslagerung bei Platzmangel</option><option value="full" ${params.gpu_offload==='full'?'selected':''}>Vollständig auf GPU · sonst Start abbrechen</option></select></label><label>Zusätzliche GPU-Reserve<select name="gpu_reserve_mode">${[['auto','Automatisch · mindestens 512 MiB je GPU'],['manual','Manuell je GPU'],['none','Keine zusätzliche Reserve']].map(([v,t])=>`<option value="${v}" ${(params.gpu_reserve_mode||'auto')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>Reserve erste GPU (MiB)<input name="reserve_first" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[0]]??512}"></label><label>Reserve zweite GPU (MiB)<input name="reserve_second" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[1]]??512}"></label><label>Erste GPU · CUDA0<select name="gpu_first">${options(selected[0],'Automatische Auswahl')}</select></label><label>Zweite GPU · CUDA1<select name="gpu_second">${options(selected[1],'Keine zweite GPU')}</select></label><label>GPU-Split<select name="split_mode">${[['none','Keine Aufteilung'],['layer','Layer-Split'],['row','Row-Split']].map(([v,t])=>`<option value="${v}" ${(params.split_mode||'none')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>GPU-Verteilung (erste, zweite GPU)<input name="tensor_split" placeholder="z. B. 85,15" value="${e((params.tensor_split||[]).join(','))}"></label></div><p class="small">Manuelle Reserven gelten für die jeweils ausgewählte GPU. Ohne Reserve entfällt nur der zusätzliche GPU-Puffer; Modell, KV-Cache und die konservative Projektor-Prognose bleiben berücksichtigt. OOM bleibt möglich. Für einen Abbruch statt CPU-Auslagerung „Vollständig auf GPU“ wählen. Die Reihenfolge legt die logischen CUDA-Geräte des Profils fest. Für Medium: RTX 5080 zuerst, RTX 3060 danach, Layer-Split und 85,15. Leere Verteilung bedeutet automatisch. Dies ist keine Zusage, dass Modell und Kontext in den verfügbaren Speicher passen.</p>`;
|
||||
return `<h3>Sprachmodell · GPU-Auswahl und Verteilung</h3><div class="form-grid"><label>GPU-Ausführung<select name="gpu_offload"><option value="auto" ${(params.gpu_offload||'auto')==='auto'?'selected':''}>Automatisch · CPU-Auslagerung bei Platzmangel</option><option value="full" ${params.gpu_offload==='full'?'selected':''}>Vollständig auf GPU · sonst Start abbrechen</option></select></label><label>Zusätzliche GPU-Reserve<select name="gpu_reserve_mode">${[['auto','Automatisch · mindestens 512 MiB je GPU'],['manual','Manuell je GPU'],['none','Keine zusätzliche Reserve']].map(([v,t])=>`<option value="${v}" ${(params.gpu_reserve_mode||'auto')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>Reserve erste GPU (MiB)<input name="reserve_first" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[0]]??512}"></label><label>Reserve zweite GPU (MiB)<input name="reserve_second" type="number" min="0" max="32768" step="1" value="${params.gpu_reserve_mib?.[selected[1]]??512}"></label><label>Erste GPU · CUDA0<select name="gpu_first">${options(selected[0],'Automatische Auswahl')}</select></label><label>Zweite GPU · CUDA1<select name="gpu_second">${options(selected[1],'Keine zweite GPU')}</select></label><label>Sprachmodell-Split<select name="split_mode">${[['none','Keine Aufteilung'],['layer','Layer-Split'],['row','Row-Split']].map(([v,t])=>`<option value="${v}" ${(params.split_mode||'none')===v?'selected':''}>${t}</option>`).join('')}</select></label><label>GPU-Verteilung (erste, zweite GPU)<input name="tensor_split" placeholder="z. B. 85,15" value="${e((params.tensor_split||[]).join(','))}"></label></div><p class="small">Manuelle Reserven gelten für die jeweils ausgewählte GPU. Ohne Reserve entfällt nur der zusätzliche GPU-Puffer; Modell, KV-Cache und die konservative Projektor-Prognose bleiben berücksichtigt. OOM bleibt möglich. Für einen Abbruch statt CPU-Auslagerung „Vollständig auf GPU“ wählen. Die Reihenfolge legt die logischen CUDA-Geräte des Profils fest. Für Medium: RTX 5080 zuerst, RTX 3060 danach, Layer-Split und 85,15. Leere Verteilung bedeutet automatisch. Dies ist keine Zusage, dass Modell und Kontext in den verfügbaren Speicher passen.</p>`;
|
||||
}
|
||||
function bindProjectorDiscovery(){
|
||||
const form=el('profile-form'),box=el('projector-discovery'),select=form.elements.vision_projector;
|
||||
@@ -62,7 +64,7 @@ window.ProfilesUI=(()=>{
|
||||
async function refreshLibrary(){
|
||||
const c=await api('catalog');if(!box.isConnected)return;
|
||||
const selected=select.value;entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));
|
||||
select.innerHTML='<option value="">Kein Projektor zugeordnet · Vision aus</option>'+projectors.map(x=>`<option value="${e(x.id)}">${e(x.repo)} / ${e(x.file)}</option>`).join('');select.value=selected;
|
||||
select.innerHTML='<option value="">Kein Projektor zugeordnet · Vision aus</option>'+projectors.map(x=>`<option value="${e(x.id)}">${e(x.download_source?.repo||x.repo)} / ${e(x.download_source?.file||x.file)}</option>`).join('');select.value=selected;
|
||||
return c;
|
||||
}
|
||||
function showFiles(detail,target,n){
|
||||
@@ -133,7 +135,7 @@ window.ProfilesUI=(()=>{
|
||||
panelSequence++;
|
||||
if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))};
|
||||
if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;}
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" placeholder="z. B. Qwen3.8 27B MEDIUM" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`<h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.repo)} / ${e(x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
||||
el('profile-editor').innerHTML=`<section class="card editor"><h2>${data.id?'Profil bearbeiten':'Profil anlegen'}</h2><form id="profile-form"><div class="form-grid"><label>API-Profilname<input name="name" required minlength="2" maxlength="64" placeholder="z. B. Qwen3.8 27B MEDIUM" value="${e(data.name)}"></label><label>Heruntergeladene Modelldatei<select name="model_id">${models.map(m=>`<option value="${m.id}" ${m.id===data.model_id?'selected':''}>${e(m.file)} · ${(m.size/1024**3).toFixed(2)} GiB</option>`).join('')}</select></label>${Object.entries(schema).map(([k,[min,max,defaultValue]])=>`<label>${e(labels[k])}<input name="${k}" type="number" required min="${min}" max="${max}" step="${['guidance','speed','temperature','top_p','mtp_min_p'].includes(k)?'any':['width','height'].includes(k)?64:1}" value="${e(data.parameters[k]??defaultValue)}"></label>`).join('')}</div>${kind==='video'?videoDevices(data):''}${kind==='chat'?gpuEditor(data.parameters)+`<h3>KV-Cache</h3><div class="form-grid">${["k","v"].map(axis=>`<label>${axis.toUpperCase()}-Cache<select name="cache_type_${axis}">${["q4_0","q8_0","f16"].map(type=>`<option value="${type}" ${(data.parameters[`cache_type_${axis}`]||"q4_0")===type?"selected":""}>${type}</option>`).join("")}</select></label>`).join("")}</div><p class="small">q4_0 spart VRAM; q8_0 und f16 brauchen mehr Speicher. K und V werden getrennt eingestellt. MTP-Draft-KV bleibt separat f16.</p><h3>Vision-Projektor</h3><label>Projektor<select name="vision_projector"><option value="">Kein Projektor zugeordnet · Vision aus</option>${projectors.map(x=>`<option value="${e(x.id)}" ${data.parameters.vision_projector===x.id?'selected':''}>${e(x.download_source?.repo||x.repo)} / ${e(x.download_source?.file||x.file)}</option>`).join('')}</select></label><section id="projector-discovery" class="card"></section><label>Projektor ausführen auf<select name="vision_device"><option value="cpu">CPU</option>${gpus.map(g=>`<option value="${e(g.uuid)}" ${data.parameters.vision_device===g.uuid?'selected':''}>${e(g.name)}</option>`).join('')}</select></label><p>Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. Das Projektor-Gerät wird unabhängig von den Sprachmodell-GPUs gewählt. Der Sprachmodell-Split verteilt ausschließlich die Sprachmodell-Gewichte. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.</p><label><input name="mtp" type="checkbox" ${data.parameters.mtp?'checked':''}> MTP aktivieren (eingebautes Draft-Modul)</label><p class="small">Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.</p>`:''}<p class="note">Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}</p><button>Profil auf Athena speichern</button><button class="secondary" type="button" id="profile-close">Schließen</button><p id="profile-error" role="alert"></p></form></section>`;
|
||||
if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='<summary>Erweitert: CPU-Threads</summary><p>CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.</p>';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));}
|
||||
if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();}
|
||||
if(kind==='video')bindVideoDevices(data);
|
||||
|
||||
+82
-19
@@ -5,6 +5,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from catalog import file_role
|
||||
|
||||
SCHEMAS={
|
||||
'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40),'mtp_tokens':(1,8,2),'mtp_min_p':(0,1,.05)},
|
||||
@@ -37,7 +38,7 @@ def chat_parameters(params):
|
||||
if params['cache_type_k'] not in ('f16','q8_0','q4_0') or params['cache_type_v'] not in ('f16','q8_0','q4_0'):raise ValueError('KV-Cache: nur f16, q8_0 oder q4_0 unterstützt.')
|
||||
if type(params['mtp']) is not bool:raise ValueError('MTP muss an oder aus sein.')
|
||||
if params['vision_projector'] is not None and (not isinstance(params['vision_projector'],str) or not re.fullmatch(r'[a-f0-9]{64}',params['vision_projector'])):raise ValueError('Ungültige Projektor-ID.')
|
||||
if params['vision_device']!='cpu' and params['vision_device'] not in params['gpu_devices']:raise ValueError('Projektor-GPU muss in der GPU-Auswahl enthalten sein.')
|
||||
if params['vision_device']!='cpu' and (not isinstance(params['vision_device'],str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',params['vision_device'])):raise ValueError('Projektor-Gerät muss CPU oder eine gültige GPU-UUID sein.')
|
||||
devices=params['gpu_devices'];split=params['tensor_split']
|
||||
if not isinstance(devices,list) or len(devices)>16 or any(not isinstance(v,str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',v) for v in devices) or len(set(devices))!=len(devices):raise ValueError('GPUs müssen als eindeutige, geordnete GPU-UUIDs angegeben werden.')
|
||||
if params['split_mode'] not in ('none','layer','row'):raise ValueError('Ungültiger GPU-Split-Modus.')
|
||||
@@ -58,14 +59,28 @@ FLUX_COMPONENTS={
|
||||
'text_encoder':{'label':'Textencoder für FLUX.2 Klein 9B','repo':FLUX_COMPONENT_REPO,'files':['split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors']},
|
||||
'vae':{'label':'VAE für FLUX.2','repo':FLUX_COMPONENT_REPO,'files':['split_files/vae/flux2-vae.safetensors']}
|
||||
}
|
||||
# One registry owns runtime, workflow, format, components and resource policy.
|
||||
IMAGE_ADAPTERS={
|
||||
'qwen_image_21':dict(label='Qwen Image 2.1',workflow='qwen',model_filename='model.gguf',suffix='.gguf',runtime='ComfyUI + ComfyUI-GGUF',reference_images=4,offload=False,components={k:{'repo':QWEN_REPO,**v} for k,v in QWEN_COMPONENTS.items()},sources={QWEN_REPO.lower(),'qwen/qwen-image-2.1','comfy-org/qwen-image-2.1'},architectures={'qwenimage21transformer2dmodel','qwen_image_2_1'}),
|
||||
'flux2_klein_9b':dict(label='FLUX.2 Klein 9B',workflow='flux',model_filename='model.safetensors',suffix='.safetensors',runtime='ComfyUI',reference_images=4,offload=True,components=FLUX_COMPONENTS,sources={FLUX_REPO.lower(),'black-forest-labs/flux.2-klein-9b','comfy-org/flux2-klein-9b'},architectures={'flux2klein9btransformer2dmodel','flux2_klein_9b'}),
|
||||
}
|
||||
def image_adapter(model):
|
||||
if not model:return None
|
||||
filename=model.get('file','')
|
||||
if not file_role(filename)['profile_eligible'] or '-of-' in filename:return None
|
||||
meta=model.get('runtime_metadata') or {};architecture=str(meta.get('architecture') or '').lower()
|
||||
lineage={str(x).lower() for x in meta.get('base_models',[]) if isinstance(x,str)}
|
||||
repo=model.get('repo','').lower()
|
||||
matches=[a for a in IMAGE_ADAPTERS.values() if filename.endswith(a['suffix']) and (repo in a['sources'] or architecture in a['architectures'] or bool(lineage & a['sources']))]
|
||||
return matches[0] if len(matches)==1 else None
|
||||
|
||||
def image_recipe(model):
|
||||
if model['repo']==QWEN_REPO and model['file'].endswith('.gguf'):return QWEN_COMPONENTS
|
||||
if model['repo']==FLUX_REPO and model['file']=='Flux.2 Klein-9B_fp16_nsfw.safetensors':return FLUX_COMPONENTS
|
||||
return None
|
||||
adapter=image_adapter(model)
|
||||
return adapter['components'] if adapter else None
|
||||
|
||||
def image_capabilities(model):
|
||||
"""Only advertise reference editing for the verified Qwen Image 2.1 workflow."""
|
||||
return {'reference_images':4 if model and model.get('repo')==QWEN_REPO and model.get('file','').endswith('.gguf') else 0}
|
||||
adapter=image_adapter(model)
|
||||
return {'reference_images':adapter['reference_images'] if adapter else 0}
|
||||
|
||||
LTX_REPO='Lightricks/LTX-2.5'
|
||||
LTX_FILE='diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors'
|
||||
@@ -78,10 +93,36 @@ LTX_COMPONENTS={
|
||||
def video_recipe(model):
|
||||
return LTX_COMPONENTS if model.get('kind')=='video' and model.get('repo')==LTX_REPO and model.get('file')==LTX_FILE else None
|
||||
|
||||
YUE2_REPO='audio-cpp/Yue2-3B-GGUF'
|
||||
YUE2_COMPONENTS={
|
||||
'vae':{'label':'YuE2-VAE · F16 empfohlen, F32 alternativ','files':['yue2-vae-f16.gguf','yue2-vae-f32.gguf']},
|
||||
'model_config':{'label':'Modellkonfiguration','files':['sidecars/yue2-model-config.json']},
|
||||
'generation_config':{'label':'Generierungskonfiguration','files':['sidecars/yue2-generation-config.json']},
|
||||
'tokenizer':{'label':'Qwen-Tokenizer','files':['sidecars/yue2-qwen.tiktoken']},
|
||||
'vae_config':{'label':'VAE-Konfiguration','files':['sidecars/yue2-vae-config.json']}
|
||||
}
|
||||
def music_recipe(model):
|
||||
return YUE2_COMPONENTS if model.get('kind')=='music' and model.get('repo')==YUE2_REPO and model.get('file') in ('yue2-3b-bf16.gguf','yue2-3b-q8_0.gguf','yue2-3b-q4_0.gguf') else None
|
||||
|
||||
def component_recipe(model):
|
||||
return video_recipe(model) or image_recipe(model)
|
||||
return music_recipe(model) or video_recipe(model) or image_recipe(model)
|
||||
|
||||
MANUAL_ROLES={'manual_text_encoder':'Textencoder (ungeprüft)','manual_vae':'VAE (ungeprüft)','manual_tokenizer':'Tokenizer (ungeprüft)','manual_configuration':'Konfiguration (ungeprüft)','manual_projector':'Projektor (ungeprüft)','manual_auxiliary':'Weitere Komponente (ungeprüft)'}
|
||||
|
||||
def audio_package_recipe(model,source):
|
||||
if not source or not model.get('repo','').startswith('audio-cpp/'):return None
|
||||
for path in sorted(Path(source,'model_specs').glob('*.json')):
|
||||
try:spec=json.loads(path.read_text())
|
||||
except (ValueError,OSError):continue
|
||||
for package in spec.get('packages',[]):
|
||||
files=package.get('files',[])
|
||||
if package.get('target_directory')!=model['repo'].split('/')[-1] or model['file'] not in files:continue
|
||||
extras=[f for f in files if isinstance(f,str) and f!=model['file'] and not f.startswith('/') and '..' not in Path(f).parts]
|
||||
return {f'runtime_file_{i}':dict(label='Laufzeit-Paket: '+f,files=[f]) for i,f in enumerate(extras)} or None
|
||||
return None
|
||||
|
||||
class Profiles:
|
||||
def _recipe(self,model):return component_recipe(model) or getattr(self,'runtime_recipe',lambda m:None)(model)
|
||||
def __init__(self,path,catalog):
|
||||
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
||||
self.image_runtime_ready=lambda:False
|
||||
@@ -99,9 +140,9 @@ class Profiles:
|
||||
p['model']=self.catalog.entry(p['model_id'])
|
||||
if p['kind']=='image':p['capabilities']=image_capabilities(p['model'])
|
||||
p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.']
|
||||
if p['kind']=='image' and (image_recipe(p['model']) or 'qwen-image-2.1' in p['model']['repo'].lower()):
|
||||
if p['kind']=='image' and image_recipe(p['model']):
|
||||
p['blockers']=['Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.']
|
||||
for role,info in reversed(list((image_recipe(p['model']) or QWEN_COMPONENTS).items())):
|
||||
for role,info in reversed(list(image_recipe(p['model']).items())):
|
||||
try:self._component(p['model'],role,p.get('components',{}).get(role))
|
||||
except ValueError:p['blockers'].insert(0,info['label']+' fehlt oder ist noch nicht zugeordnet.')
|
||||
except ValueError as exc:p['model']=None;p['blockers']=[str(exc)]
|
||||
@@ -111,14 +152,20 @@ class Profiles:
|
||||
for role,info in video_recipe(p['model']).items():
|
||||
try:self._component(p['model'],role,p.get('components',{}).get(role))
|
||||
except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
|
||||
if p.get('model') and music_recipe(p['model']):
|
||||
for role,info in music_recipe(p['model']).items():
|
||||
try:self._component(p['model'],role,p.get('components',{}).get(role))
|
||||
except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.')
|
||||
if p['kind']=='video' and self.video_blockers and p.get('model'):p['blockers']=self.video_blockers(p)
|
||||
if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p)
|
||||
if p['kind']=='music' and getattr(self,'music_blockers',None) and p.get('model'):p['blockers']=self.music_blockers(p)
|
||||
if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p)
|
||||
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p)
|
||||
if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.')
|
||||
if p['kind']=='image':
|
||||
for task,repo in PROMPT_ENHANCER_REPOS.items():
|
||||
if p['prompt_enhancer'].get(task)==repo and not self.prompt_enhancer_ready(task):p['blockers'].append('Prompt-Aufwerter '+task.upper()+' fehlt. Unter Bildgenerierung → Aufwerter installieren.')
|
||||
if any(k in MANUAL_ROLES for k in p.get('components',{})):p['blockers'].append('Manuelle Zusatzkomponenten sind ungeprüft und noch nicht an den Worker angebunden.')
|
||||
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
|
||||
return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}}
|
||||
def save(self,data):
|
||||
@@ -187,16 +234,20 @@ class Profiles:
|
||||
return {'deleted':old['id']}
|
||||
|
||||
def _component(self,model,role,ident):
|
||||
recipe=component_recipe(model)
|
||||
if role in MANUAL_ROLES:
|
||||
item=self.catalog.entry(ident)
|
||||
if item['kind']!=model['kind'] or item['id']==model['id']:raise ValueError('Andere Zusatzdatei aus derselben Kategorie wählen.')
|
||||
return item
|
||||
recipe=self._recipe(model)
|
||||
if not recipe or role not in recipe:raise ValueError('Für dieses Modell ist kein geprüftes Komponentenrezept hinterlegt.')
|
||||
item=self.catalog.entry(ident)
|
||||
if item['repo']!=recipe[role].get('repo',model['repo']) or item['kind']!=model['kind'] or item['file'] not in recipe[role]['files']:raise ValueError('Die Datei gehört nicht zu dieser Komponente des Modells.')
|
||||
if video_recipe(model) and item['revision']!=model['revision']:raise ValueError('LTX-Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.')
|
||||
if (video_recipe(model) or music_recipe(model)) and item['revision']!=model['revision']:raise ValueError('Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.')
|
||||
return item
|
||||
def components(self,model_id):
|
||||
def components(self,model_id,extra_repo=None):
|
||||
model=self.catalog.entry(model_id)
|
||||
if not component_recipe(model):return dict(supported=False,requirements=[],message='Für dieses Modell ist noch kein Komponentenrezept hinterlegt. Keine automatische Zuordnung.')
|
||||
recipe=component_recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
|
||||
if extra_repo or not self._recipe(model):return self.manual_components(model,extra_repo)
|
||||
recipe=self._recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video or music_recipe(model) else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
|
||||
for role,info in recipe.items():
|
||||
available=[]
|
||||
for item in entries:
|
||||
@@ -205,17 +256,29 @@ class Profiles:
|
||||
except ValueError:pass
|
||||
candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name]
|
||||
requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates))
|
||||
if music_recipe(model):return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='audio.cpp',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Passendes YuE2-GGUF-Paket: je eine VAE-Datei und die vier Zusatzdateien derselben Quellversion. F16-VAE ist die erste Downloadempfehlung. Vorhandene Dateien wiederverwenden, fehlende herunterladen und danach zuordnen. Die YuE2-Musikanbindung verwendet diese Dateien über audio.cpp. Speichern startet kein Modell; anschließend Musik → Testen verwenden.')
|
||||
if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='ComfyUI · gemeinsame Bild- und Videolaufzeit',runtime_installed=bool(getattr(self,'video_runtime_ready',lambda:False)()),message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Vorhandene ComfyUI-Laufzeit wiederverwenden; Videomodell im Bereich Video aktivieren. Vollständige Dateien sind keine Speicherzusage.')
|
||||
return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(),
|
||||
message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.')
|
||||
if not image_recipe(model):return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='audio.cpp · Paketdefinition',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Zusatzdateien aus der installierten Laufzeit-Paketdefinition. Weitere modellabhängige Pakete können erforderlich sein; dies ist keine vollständige Worker-Zusage.')
|
||||
return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime=image_adapter(model)['runtime'],runtime_installed=self.image_runtime_ready(),
|
||||
message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if image_adapter(model)['workflow']=='flux' else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.')
|
||||
def manual_components(self,model,repo=None):
|
||||
source=self.catalog.files(repo or model['repo'],None if repo else model['revision'])
|
||||
entries=self.catalog.status()['entries'];requirements=[]
|
||||
for role,label in MANUAL_ROLES.items():
|
||||
match={'manual_text_encoder':('text_encoder',),'manual_vae':('vae',),'manual_projector':('vision_projector','audio_projector'),'manual_configuration':('configuration',),'manual_auxiliary':('auxiliary',),'manual_tokenizer':('configuration','auxiliary')}[role]
|
||||
def matches(name):return name.endswith('.tiktoken') or 'tokenizer' in name.lower() if role=='manual_tokenizer' else file_role(name)['role'] in match
|
||||
candidates=[dict(f,repo=source['repo'],revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for f in source['files'] if f['name']!=model['file'] and matches(f['name'])]
|
||||
available=[x for x in entries if x['kind']==model['kind'] and x['id']!=model['id'] and matches(x['file'])]
|
||||
requirements.append(dict(role=role,label=label,available=available,candidates=candidates,optional=True))
|
||||
return dict(supported=True,manual=True,requirements=requirements,source=source['url'],license=source['license'],runtime='Siehe Laufzeiterkennung am Modell',runtime_installed=False,message='Kein vollständiges geprüftes Rezept für diese Auswahl. Diese Dateien sind Hinweise aus Dateinamen und keine bestätigten Pflichtkomponenten. Manuell ausgewählte Dateien werden als ungeprüft gespeichert und schalten keinen Worker frei.')
|
||||
def assign(self,data):
|
||||
if set(data)!={'id','revision','components'} or not isinstance(data['components'],dict):raise ValueError('Ungültige Komponentenzuordnung.')
|
||||
with self.lock:
|
||||
old=next((p for p in self.rows if p['id']==data['id']),None)
|
||||
if not old or old['kind'] not in ('image','video') or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
|
||||
if not old or old['kind'] not in SCHEMAS or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
|
||||
model=self.catalog.entry(old['model_id'])
|
||||
if set(data['components'])-set(component_recipe(model) or {}):raise ValueError('Unbekannte Komponente für dieses Modell.')
|
||||
components={k:v for k,v in data['components'].items() if v}
|
||||
if set(data['components'])-(set(self._recipe(model) or {})|set(MANUAL_ROLES)):raise ValueError('Unbekannte Komponente für dieses Modell.')
|
||||
components={k:v for k,v in {**old.get('components',{}),**data['components']}.items() if v}
|
||||
for role,ident in components.items():self._component(model,role,ident)
|
||||
updated=dict(old,components=components,revision=old['revision']+1,updated_at=time.time())
|
||||
rows=[updated if p is old else p for p in self.rows]
|
||||
|
||||
+4
-2
@@ -1,8 +1,10 @@
|
||||
const RuntimeUI=(()=>{
|
||||
let preparedRevision='';
|
||||
function prepareBuild(revision){preparedRevision=revision;}
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(path='',data){const r=await fetch('/api/v1/runtime'+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(){return `<div id="runtime-live"><div class="kicker">ATHENA / LLAMA.CPP</div><h1>llama.cpp & Updates</h1><p>Offizielle Versionen getrennt bauen und prüfen. Ein Build lädt kein Modell und ändert keine produktiven Laufzeiten.</p><section class="card"><h2>Voraussetzungen</h2><button id="rt-check">Werkzeuge & GPUs prüfen</button><div id="rt-prereq"></div></section><section class="card"><h2>Installation & Build</h2><form id="rt-build"><label>Release oder vollständiger Commit<input name="revision" required pattern="b[0-9]{3,8}|v[0-9]{1,4}[.][0-9]{1,4}[.][0-9]{1,4}|[a-f0-9]{40}" placeholder="Unten Releases abfragen und auswählen"></label><label>Backend<select name="backend"><option>CUDA</option><option>CPU</option></select></label><label>Parallele Build-Jobs<select name="jobs"><option>1</option><option>2</option></select></label><p>GPU-Architekturen werden automatisch aus beiden Karten übernommen. Begrenzte Build-Last für den Parallelbetrieb.</p><button>llama.cpp herunterladen & bauen</button></form><button id="rt-cancel" class="secondary">Laufenden Build abbrechen</button><p id="rt-message" role="status"></p><div id="rt-job"></div><details><summary>Build-Ausgabe (eigener Build)</summary><pre id="rt-log" style="max-height:320px;overflow:auto;white-space:pre-wrap"></pre></details></section><section class="card"><h2>Versionen & Änderungen</h2><p>Das aktuelle reguläre Release steht zuerst. Nightlies enthalten neuere Änderungen, sind aber keine pauschale Empfehlung. Modell-, MTP- und Projektor-Kompatibilität vor einem Standardwechsel testen.</p><button id="rt-releases">Nach Updates suchen</button><div id="rt-releases-list"></div></section><section class="card"><h2>Geprüfte Builds</h2><div id="rt-builds"></div><button id="rt-rollback" class="secondary">Vorherigen Standard wiederherstellen</button><p>Als Standard auswählen startet keinen Prozess. Bestehende Router bleiben unverändert.</p></section><section class="card"><h2>Automatische Speicheranpassung</h2><p>Kein pauschales Reservefeld: Die Laufzeit soll Modell, gewünschten Kontext, Slots und den aktuell freien Speicher gemeinsam berücksichtigen. Unterstützte Builds verwenden llama.cpp <code>--fit on</code>; der Kontext wird ausdrücklich vorgegeben. Keine absichtlichen OOM-Tests auf den produktiv genutzten GPUs.</p><form id="rt-fit"><label>Chatprofil auswählen<select name="profile" id="rt-profiles"></select></label><label>Gewünschter Gesamtkontext<input name="context" type="number" min="512" max="2097152" value="160000" required></label><label>Slots<input name="slots" type="number" min="1" max="16" value="2" required></label><button>Auto-Einpassung berechnen</button></form><pre id="rt-fit-result" style="white-space:pre-wrap"></pre><p>Ein Build allein kann keine Modell-Layerzahl bestimmen. Die Berechnung verwendet llama-fit-params ohne Gewichtsallokation. Sie ist eine Prognose für das Textmodell und kein Lasttest. Vision-Projektor und MTP sind noch nicht eingerechnet. Ein Modellstart erfolgt nicht. Die Prognose verwendet aktuell freien VRAM; falls das gewählte Modell bereits läuft, belegt es diesen Speicher weiterhin. Vor einer realistischen Neuplanung das Deck-Modell bei Bedarf bewusst entladen.</p></section></div>`;}
|
||||
function bind(){const root=document.querySelector('#runtime-live');if(!root)return;let running=false;
|
||||
function bind(){const root=document.querySelector('#runtime-live');if(!root)return;let running=false;if(preparedRevision){root.querySelector('#rt-build [name=revision]').value=preparedRevision;preparedRevision='';}
|
||||
const msg=t=>{if(root.isConnected)root.querySelector('#rt-message').textContent=t;};
|
||||
async function status(){try{const s=await api();if(!root.isConnected)return;running=s.job?.state==='running';root.querySelector('#rt-job').textContent=s.job?`${s.job.revision} · ${s.job.state} · ${s.job.phase} ${s.job.error||''}`:'Noch kein Build gestartet.';root.querySelector('#rt-cancel').disabled=!running;root.querySelector('#rt-build button').disabled=running;root.querySelector('#rt-rollback').disabled=!s.previous;root.querySelector('#rt-builds').innerHTML=s.builds.map(b=>`<article><h3>${e(b.revision)} · ${e(b.backend)} ${s.active===b.id?'· STANDARD':''}</h3><p>Commit ${e(b.commit)} · GPU-Ziele ${e(b.architectures.join(', ')||'CPU')} · Auto-Fit ${b.fit_supported?'unterstützt':'nicht unterstützt'}</p><button data-build="${e(b.id)}" ${s.active===b.id?'disabled':''}>Als Standard auswählen</button></article>`).join('')||'<p>Noch kein geprüfter Build vorhanden.</p>';root.querySelectorAll('[data-build]').forEach(b=>b.onclick=()=>action('/activate',{build_id:b.dataset.build}));if(s.job){const log=await api('/log');if(root.isConnected)root.querySelector('#rt-log').textContent=log.text;}if(running)setTimeout(()=>{if(root.isConnected)status();},3000);}catch(err){msg(err.message);}}
|
||||
async function action(path,data){try{await api(path,data);msg('Aktion angenommen.');await status();}catch(err){msg(err.message);}}
|
||||
@@ -15,5 +17,5 @@ const RuntimeUI=(()=>{
|
||||
root.querySelector('#rt-fit').onsubmit=async event=>{event.preventDefault();const b=event.target.querySelector('button');b.disabled=true;msg('Modellmetadaten und Speicherbedarf werden berechnet …');const d=Object.fromEntries(new FormData(event.target));d.context=Number(d.context);d.slots=Number(d.slots);try{const v=await api('/fit',d);if(root.isConnected)root.querySelector('#rt-fit-result').textContent=(v.success?'Prognose erfolgreich. Kein Modell geladen.':'Aktuell keine passende Einpassung ermittelt.')+(v.within_ram_limit===false?'\nNICHT STARTBAR in der Testinstanz: berechneter Host-RAM überschreitet deren Limit von '+v.ram_limit_mib+' MiB.':'')+'\nGPU-Layer (Prognose): '+(v.fitted?.gpu_layers??'nicht ermittelt')+'\nGPU-Verteilung: '+(v.fitted?.tensor_split??'ein Gerät / nicht ermittelt')+'\nAusgelassene belegte GPUs: '+v.excluded_gpus.join(', ')+'\nAutomatische Reserve (MiB): '+v.reserve_mib.join(', ')+'\n'+v.memory.map(m=>`${m.device}: Modell ${m.model_mib} MiB · Kontext ${m.context_mib} MiB · Rechenspeicher ${m.compute_mib} MiB`).join('\n')+'\n'+v.arguments+'\n'+v.details;msg('Berechnung abgeschlossen. Freier Speicher kann sich durch andere Dienste ändern.');}catch(err){msg(err.message);}finally{b.disabled=false;}};
|
||||
prerequisites();status();
|
||||
}
|
||||
return {html,bind};
|
||||
return {html,bind,prepareBuild};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
window.SeparatorUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(path,data){const response=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const value=await response.json();if(!response.ok)throw Error(value.error||'Anfrage fehlgeschlagen');return value;}
|
||||
function render(test=false){if(document.querySelector('#separator-panel')?.dataset.test===String(test))return;
|
||||
document.querySelector('#view').innerHTML=`<div id="separator-panel" data-test="${test}"><div class="kicker">${test?'KI-WERKZEUGE / AUDIO-TRENNUNG':'EINSTELLUNGEN / LAUFZEITEN'}</div><h1>${test?'Audio-Trennung':'Audio Separator'}</h1><p>BS-RoFormer und MelBand RoFormer trennen Gesang und Instrumental. Die native Laufzeit wird einmal installiert und für passende Modellpakete wiederverwendet.</p>${test?'<p><a href="http://127.0.0.1:8128/" target="_blank" rel="noopener">Audio-Trennoberfläche öffnen →</a> · auf dem Client ist der SSH-Tunnel für Port 8128 erforderlich.</p>':''}<p id="sep-message" role="status"></p><section class="card"><div id="sep-status"></div><button id="sep-install">Laufzeit installieren</button><button class="secondary" id="sep-cancel">Einrichtung abbrechen</button><p>Version 0.47.0 · mindestens 15 GiB frei. Installiert CUDA-PyTorch und Audio Separator in einer eigenen Umgebung. Keine Änderungen an Host-Treibern oder alten Diensten.</p><div id="sep-models"></div><details><summary>Installationsausgabe</summary><pre id="sep-log" style="max-height:240px;overflow:auto;white-space:pre-wrap"></pre></details></section><section class="card"><h2>Kurzer Trenntest</h2><p>PCM-WAV, 16 Bit, Mono oder Stereo, bis 30 Sekunden und 8 MiB. Im LLM-Modus koordiniert Deck die GPU-Nutzung und entlädt gegebenenfalls eigene Modelle. Fremde Dienste bleiben unberührt. Das Trennmodell wird nach dem Test entladen.</p><form id="sep-test"><label>Modellpaket<select name="model" id="sep-model"></select></label><label>Audiodatei<input name="file" type="file" accept=".wav,audio/wav" required></label><button>Audio trennen</button><button type="button" class="secondary" id="sep-test-cancel">Test abbrechen</button></form><div id="sep-job" role="status"></div><div id="sep-results"></div></section></div>`;
|
||||
const root=document.querySelector('#separator-panel'),msg=root.querySelector('#sep-message');let pending=false,signature='';
|
||||
async function action(path,data){pending=true;try{await api(path,data);msg.textContent='Aktion gestartet.';}catch(error){msg.textContent=error.message;}finally{pending=false;signature='';poll();}}
|
||||
async function poll(){if(!root.isConnected)return;try{const [s,t]=await Promise.all([api('separator-runtime'),api('separator-tests')]);if(!root.isConnected)return;
|
||||
const running=s.job?.state==='running';if(s.job){const log=await api('separator-runtime/log');if(!root.isConnected)return;root.querySelector('#sep-log').textContent=log.text;}root.querySelector('#sep-status').innerHTML=`<p><strong>${s.installed?'Installiert':'Noch nicht installiert'}</strong> · ${e(s.version)}</p>${s.job?`<p>${e(s.job.state)} · ${e(s.job.phase)}</p>${running?'<progress aria-label="Einrichtung läuft"></progress>':''}`:''}`;
|
||||
root.querySelector('#sep-install').disabled=pending||running||s.installed;root.querySelector('#sep-cancel').disabled=pending||!running;
|
||||
const key=JSON.stringify(s.models);if(key!==signature){signature=key;root.querySelector('#sep-models').innerHTML='<h2>Unterstützte Modellpakete</h2><p>Gewichte und passende Konfiguration werden zusammen beschafft. Diese Pakete sind getrennt von ungeprüften Hugging-Face-Dateien.</p>'+s.models.map(m=>`<p><strong>${e(m.name)}</strong> · ${m.installed?'Bereit':'Fehlt'}<br><span class="small">Quelle: ${e(m.source?.publisher||'Nicht belegt')} · geprüftes Komplettpaket</span><details><summary>Originalquellen und Prüfsummen</summary>${(m.source?.files||[]).map(f=>`<p>${e(f.file)}<br>${f.urls.map(u=>`<a href="${e(u)}" target="_blank" rel="noopener">Downloadquelle</a>`).join(" · ")}<br><code>${e(f.sha256)}</code></p>`).join("")}</details><br><span class="small">${e(m.id)} + ${e(m.configuration)}</span><br><button data-sep-model="${e(m.id)}" ${m.installed?'disabled':''}>Modell und Konfiguration herunterladen</button></p>`).join('');root.querySelector('#sep-model').innerHTML=s.models.filter(m=>m.installed).map(m=>`<option value="${e(m.id)}">${e(m.name)}</option>`).join('');root.querySelectorAll('[data-sep-model]').forEach(b=>b.onclick=()=>action('separator-runtime/download',{model:b.dataset.sepModel}));}
|
||||
root.querySelectorAll('[data-sep-model]').forEach(b=>b.disabled=pending||running||!s.installed||s.models.find(m=>m.id===b.dataset.sepModel)?.installed);
|
||||
const j=t.job;root.querySelector('#sep-test button').disabled=pending||running||!s.models.some(m=>m.installed)||j?.state==='running';root.querySelector('#sep-test-cancel').disabled=j?.state!=='running';root.querySelector('#sep-job').innerHTML=j?`<p>${e(j.state)} · ${e(j.phase)} ${j.elapsed_seconds?e(j.elapsed_seconds)+' s':''}</p>${j.state==='running'?'<progress aria-label="Audio wird getrennt"></progress>':''}`:'';
|
||||
const results=root.querySelector('#sep-results');if(results.dataset.job!==String(j?.id+':'+j?.state)){results.dataset.job=String(j?.id+':'+j?.state);results.innerHTML=j?.state==='complete'?j.files.map(f=>`<p>${e(f.name)}</p><audio controls src="/api/v1/separator-tests/audio?id=${e(j.id)}&index=${f.index}"></audio><p><a href="/api/v1/separator-tests/audio?id=${e(j.id)}&index=${f.index}" download="${e(f.name)}">Spur herunterladen</a></p>`).join(''):'';}
|
||||
}catch(error){if(root.isConnected)msg.textContent=error.message;}clearTimeout(root.pollTimer);if(root.isConnected)root.pollTimer=setTimeout(poll,3000);}
|
||||
root.querySelector('#sep-install').onclick=()=>action('separator-runtime/install',{});root.querySelector('#sep-cancel').onclick=()=>action('separator-runtime/cancel',{});root.querySelector('#sep-test-cancel').onclick=()=>action('separator-tests/cancel',{});
|
||||
root.querySelector('#sep-test').onsubmit=async event=>{event.preventDefault();pending=true;msg.textContent='Audio wird hochgeladen …';try{const response=await fetch('/api/v1/separator-tests/start',{method:'POST',headers:{'X-Athena-Deck':'1'},body:new FormData(event.target)}),value=await response.json();if(!response.ok)throw Error(value.error);msg.textContent='Trenntest gestartet.';}catch(error){msg.textContent=error.message;}finally{pending=false;poll();}};poll();
|
||||
}
|
||||
return {render};
|
||||
})();
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Bounded WAV tests with coordinated GPU ownership and disposable input."""
|
||||
import io,json,os,signal,subprocess,threading,time,uuid,wave
|
||||
from pathlib import Path
|
||||
from image_test import probe
|
||||
from separator_runtime import MODELS
|
||||
class SeparatorTests:
|
||||
def __init__(self,root,runtime,scheduler):
|
||||
self.root=Path(root);self.runtime=runtime;self.scheduler=scheduler;self.lock=threading.RLock();self.job=None;self.process=None;self.cancel=threading.Event();self.thread=None
|
||||
def status(self):
|
||||
with self.lock:return dict(job=dict(self.job) if self.job else None)
|
||||
def start(self,model,audio,target=None,full=False):
|
||||
targets={'vocals':'model_bs_roformer_ep_317_sdr_12.9755.ckpt','drums':'htdemucs_ft.yaml','bass':'htdemucs_ft.yaml','guitar':'htdemucs_6s.yaml','piano':'htdemucs_6s.yaml','other':'htdemucs_6s.yaml'}
|
||||
if target is not None and targets.get(target)!=model:raise ValueError('Zielspur und Modellpaket passen nicht zusammen.')
|
||||
if not isinstance(model,str) or model not in MODELS or not self.runtime.status()['installed'] or not self.runtime.model_ready(model):raise ValueError('Laufzeit und Modellpaket zuerst unter Audio Separator einrichten.')
|
||||
if not isinstance(audio,bytes) or not 44<=len(audio)<=(256 if full else 8)*1024**2:raise ValueError('WAV überschreitet die erlaubte Upload-Größe.')
|
||||
try:
|
||||
with wave.open(io.BytesIO(audio)) as wav:
|
||||
if wav.getnchannels() not in (1,2) or wav.getsampwidth()!=2 or not 8000<=wav.getframerate()<=48000 or not 0<wav.getnframes()/wav.getframerate()<=(1200 if full else 30):raise ValueError('PCM-WAV, 16 Bit, Mono/Stereo, maximal 30 Sekunden erforderlich.')
|
||||
if len(wav.readframes(wav.getnframes()))!=wav.getnframes()*wav.getnchannels()*2:raise ValueError('WAV-Datei ist unvollständig.')
|
||||
except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei.') from None
|
||||
with self.lock:
|
||||
if self.job and self.job['state']=='running':raise ValueError('Trenntest läuft bereits.')
|
||||
self.target=target;self.full=full;self.cancel.clear();self.root.mkdir(parents=True,exist_ok=True,mode=0o700);self.job=dict(id=uuid.uuid4().hex,state='running',phase='Wartet auf GPU-Freigabe',started_at=time.time(),files=[])
|
||||
self.thread=threading.Thread(target=self._run,args=(model,audio),daemon=True);self.thread.start();return dict(self.job)
|
||||
def _run(self,model,audio):
|
||||
release=None;directory=self.root/self.job['id'];state='failed';phase='Trennung fehlgeschlagen.'
|
||||
try:
|
||||
release=self.scheduler.image_reservation(kind='separator')
|
||||
gpu=next((g for term in ('RTX 3060','RTX 5080') for g in probe() if term in g['name'] and not g['processes'] and g['free_mib']>4000),None)
|
||||
if not gpu:raise ValueError('Keine ausreichend freie GPU. Fremde Dienste werden nicht beendet.')
|
||||
directory.mkdir(mode=0o700);(directory/'input.wav').write_bytes(audio);diagnostics=(directory/'worker.tmp').open('wb')
|
||||
with self.lock:
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
self.job.update(phase='Modell laden und Audio trennen',gpu=gpu['name'])
|
||||
self.process=subprocess.Popen([str(self.runtime.paths()[0]),str(Path(__file__).with_name('separator_worker.py')),str(self.runtime.root/'models'),model,str(directory/'input.wav'),str(directory),getattr(self,'target',None) or ''],stdout=subprocess.DEVNULL,stderr=diagnostics,start_new_session=True,env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid'],OMP_NUM_THREADS='6'))
|
||||
# Drain diagnostics into a bounded temporary file rather than retaining user data.
|
||||
deadline=time.monotonic()+(7200 if getattr(self,'full',False) else 600)
|
||||
while self.process.poll() is None:
|
||||
if self.cancel.wait(.2):raise InterruptedError()
|
||||
if time.monotonic()>deadline:raise ValueError('Trennauftrag überschreitet das Zeitlimit.')
|
||||
with self.lock:self.job['elapsed_seconds']=round(time.time()-self.job['started_at'])
|
||||
diagnostics.close();error=(directory/'worker.tmp').read_bytes()[-65536:].decode(errors='replace')
|
||||
if self.process.returncode:raise ValueError('GPU-Speicher reicht nicht (OOM).' if 'out of memory' in error.lower() else 'Audio Separator hat den Test abgebrochen. Modellpaket und Laufzeit prüfen.')
|
||||
names=json.loads((directory/'results.json').read_text())
|
||||
if not names or any(Path(n).name!=n or not n.endswith('.wav') or not (directory/n).is_file() for n in names):raise ValueError('Ungültige Ergebnisdateien.')
|
||||
with self.lock:self.job['files']=[dict(index=i,name=n) for i,n in enumerate(names)]
|
||||
(directory/'complete').touch();state='complete';phase='Spuren fertig · Modell entladen'
|
||||
except InterruptedError:state='cancelled';phase='Trenntest abgebrochen'
|
||||
except Exception as exc:phase=str(exc) if isinstance(exc,ValueError) else 'Trenntest fehlgeschlagen.'
|
||||
finally:
|
||||
process=self.process
|
||||
if process and process.poll() is None:
|
||||
try:os.killpg(process.pid,signal.SIGTERM);process.wait(5)
|
||||
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
|
||||
except ProcessLookupError:pass
|
||||
if 'diagnostics' in locals():diagnostics.close()
|
||||
(directory/'input.wav').unlink(missing_ok=True);(directory/'worker.tmp').unlink(missing_ok=True)
|
||||
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time())
|
||||
if release:release()
|
||||
def stop(self):
|
||||
self.cancel.set()
|
||||
if self.thread:self.thread.join(7)
|
||||
return {'cancellation_requested':True}
|
||||
def audio(self,ident,index):
|
||||
if not isinstance(ident,str) or len(ident)!=32 or any(c not in '0123456789abcdef' for c in ident):raise ValueError('Ungültige Auftrag-ID.')
|
||||
directory=self.root/ident
|
||||
if not (directory/'complete').is_file():raise ValueError('Auftrag nicht fertig.')
|
||||
names=json.loads((directory/'results.json').read_text())
|
||||
if type(index) is not int or not 0<=index<len(names) or Path(names[index]).name!=names[index]:raise ValueError('Ungültige Spur.')
|
||||
path=directory/names[index]
|
||||
if path.is_symlink() or path.resolve().parent!=directory.resolve():raise ValueError('Ungültiger Ergebnispfad.')
|
||||
if path.stat().st_size>512*1024**2:raise ValueError('Spur zu groß.')
|
||||
return path.read_bytes()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Owned audio-separator environment and explicitly supported model packages."""
|
||||
import hashlib,json,os,shutil,sys,time
|
||||
from pathlib import Path
|
||||
from ltx_original_runtime import LTXOriginalRuntime
|
||||
VERSION='0.47.0'
|
||||
SOURCES=json.loads(Path(__file__).with_name('separator_sources.json').read_text())
|
||||
def package_files(name):return [x['file'] for x in SOURCES[name]['files']]
|
||||
def verify_package(directory,name):
|
||||
for item in SOURCES[name]['files']:
|
||||
path=Path(directory)/item['file']
|
||||
if not path.is_file() or path.is_symlink():return False
|
||||
digest=hashlib.sha256()
|
||||
with path.open('rb') as stream:
|
||||
for chunk in iter(lambda:stream.read(1024**2),b''):digest.update(chunk)
|
||||
if digest.hexdigest()!=item['sha256']:return False
|
||||
return True
|
||||
MODELS={
|
||||
'vocals_mel_band_roformer.ckpt':('MelBand RoFormer · Gesang / Instrumental','vocals_mel_band_roformer.yaml'),
|
||||
'model_bs_roformer_ep_317_sdr_12.9755.ckpt':('BS-RoFormer · Gesang / Instrumental','model_bs_roformer_ep_317_sdr_12.9755.yaml'),
|
||||
'htdemucs_ft.yaml':('Demucs FT · Gesang / Schlagzeug / Bass / Rest','htdemucs_ft.yaml'),
|
||||
'htdemucs_6s.yaml':('Demucs 6s · zusätzlich Gitarre / Piano','htdemucs_6s.yaml')}
|
||||
class SeparatorRuntime(LTXOriginalRuntime):
|
||||
name='Audio Separator'
|
||||
def __init__(self,root):super().__init__(root,None)
|
||||
def paths(self):
|
||||
marker=self.root/'active.json'
|
||||
if marker.is_file():
|
||||
ident=json.loads(marker.read_text()).get('id','')
|
||||
if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):
|
||||
base=self.root/ident
|
||||
if (base/'ready').is_file():return base/'python/bin/python',base
|
||||
return self.root/'missing-python',self.root/'missing-runtime'
|
||||
def status(self):
|
||||
python,base=self.paths()
|
||||
return dict(installed=python.is_file() and (base/'ready').is_file(),version=VERSION,required_disk_gib=15,job=dict(self.job) if self.job else None,models=[dict(id=name,name=label,configuration=config,source=SOURCES[name],installed=self.model_ready(name)) for name,(label,config) in MODELS.items()])
|
||||
def model_ready(self,name):
|
||||
if name not in MODELS:return False
|
||||
directory=self.root/'models';marker=directory/(name+'.ready')
|
||||
try:
|
||||
recorded=json.loads(marker.read_text())
|
||||
return recorded=={x['file']:x['sha256'] for x in SOURCES[name]['files']} and all((directory/f).is_file() and not (directory/f).is_symlink() and (directory/f).stat().st_size>0 for f in package_files(name))
|
||||
except (OSError,ValueError):return False
|
||||
|
||||
def start(self):
|
||||
if not shutil.which('ffmpeg'):raise ValueError('FFmpeg fehlt. Deck-Systemvoraussetzungen installieren.')
|
||||
with self.lock:
|
||||
if self.job and self.job['state']=='running':raise ValueError('Einrichtungsauftrag läuft bereits.')
|
||||
self.requested_model=None
|
||||
return super().start()
|
||||
def download(self,name):
|
||||
import threading,uuid
|
||||
if not isinstance(name,str) or name not in MODELS:raise ValueError('Dieses Modellpaket ist nicht unterstützt.')
|
||||
with self.lock:
|
||||
if not self.status()['installed']:raise ValueError('Zuerst Audio Separator installieren.')
|
||||
if self.job and self.job['state']=='running':raise ValueError('Einrichtungsauftrag läuft bereits.')
|
||||
if shutil.disk_usage(self.root).free<3*1024**3:raise ValueError('Mindestens 3 GiB freier Speicher erforderlich.')
|
||||
self.cancel.clear();self.requested_model=name;self.job=dict(id=uuid.uuid4().hex,state='running',phase='Modellpaket herunterladen',started_at=time.time());self._save()
|
||||
self.worker=threading.Thread(target=self._run,daemon=True);self.worker.start();return self.status()
|
||||
def _run(self):
|
||||
base=self.root/self.job['id'];state='failed';phase='Einrichtung fehlgeschlagen.';model=getattr(self,'requested_model',None)
|
||||
try:
|
||||
if model:
|
||||
directory=base;directory.mkdir(mode=0o700)
|
||||
import urllib.request
|
||||
for item in SOURCES[model]['files']:
|
||||
self._phase('Modellpaket: '+item['file'])
|
||||
succeeded=False
|
||||
for url in item['urls']:
|
||||
try:
|
||||
digest=hashlib.sha256();size=0
|
||||
with urllib.request.urlopen(url,timeout=60) as response,(directory/item['file']).open('wb') as out:
|
||||
while chunk:=response.read(1024**2):
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
size+=len(chunk)
|
||||
if size>2*1024**3:raise ValueError('Quelldatei zu groß.')
|
||||
digest.update(chunk);out.write(chunk)
|
||||
if digest.hexdigest()!=item['sha256']:raise ValueError('Quelldatei stimmt nicht mit dem geprüften Rezept überein.')
|
||||
succeeded=True;break
|
||||
except InterruptedError:raise
|
||||
except Exception:continue
|
||||
if not succeeded:raise ValueError('Geprüfte Quelle nicht verfügbar: '+item['file'])
|
||||
filenames=package_files(model)
|
||||
if not verify_package(directory,model):raise ValueError('Modellpaket-Prüfsumme stimmt nicht.')
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
destination=self.root/'models';destination.mkdir(exist_ok=True);hashes={}
|
||||
for f in filenames:
|
||||
digest=hashlib.sha256()
|
||||
with (directory/f).open('rb') as source:
|
||||
for chunk in iter(lambda:source.read(1024**2),b''):digest.update(chunk)
|
||||
hashes[f]=digest.hexdigest();(directory/f).replace(destination/f)
|
||||
(destination/(model+'.ready')).write_text(json.dumps(hashes))
|
||||
state='complete';phase='Modell und passende Konfiguration heruntergeladen. Kein Modell geladen.'
|
||||
else:
|
||||
base.mkdir(mode=0o700);tmp=base/'tmp';tmp.mkdir()
|
||||
env=dict(os.environ,TMPDIR=str(tmp),PIP_NO_INPUT='1',PIP_DISABLE_PIP_VERSION_CHECK='1')
|
||||
python=base/'python/bin/python';self._phase('Eigene Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python'],env)
|
||||
self._phase('CUDA-PyTorch herunterladen · mehrere GiB');self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchaudio==2.11.0','--index-url','https://download.pytorch.org/whl/cu128'],env)
|
||||
self._phase('Audio Separator und GPU-Abhängigkeiten installieren');self._command([python,'-m','pip','install','--no-cache-dir',f'audio-separator[gpu]=={VERSION}','onnxruntime-gpu==1.22.0','audioread==3.1.0'],env)
|
||||
self._phase('Paketkonsistenz und CUDA-Unterstützung prüfen');self._command([python,'-m','pip','check'],env)
|
||||
self._command([python,'-c','import torch; from audio_separator.separator import Separator; assert torch.version.cuda'],env)
|
||||
if self.cancel.is_set():raise InterruptedError()
|
||||
(base/'ready').write_text(VERSION);marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json')
|
||||
state='complete';phase='Audio Separator installiert. Jetzt Modellpaket auswählen.'
|
||||
except InterruptedError:state='cancelled';phase='Einrichtung abgebrochen.'
|
||||
except Exception as exc:phase=str(exc)
|
||||
finally:
|
||||
if model or state!='complete':shutil.rmtree(base,ignore_errors=True)
|
||||
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"vocals_mel_band_roformer.ckpt": {
|
||||
"recipe_version": 1,
|
||||
"publisher": "Kimberley Jensen / UVR",
|
||||
"homepage": "https://github.com/nomadkaraoke/python-audio-separator",
|
||||
"files": [
|
||||
{
|
||||
"file": "vocals_mel_band_roformer.ckpt",
|
||||
"sha256": "87201f4d31afb5bc79993230fc49446918425574db48c01c405e44f365c7559e",
|
||||
"urls": [
|
||||
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/vocals_mel_band_roformer.ckpt",
|
||||
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/vocals_mel_band_roformer.ckpt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "vocals_mel_band_roformer.yaml",
|
||||
"sha256": "b958b29c8f7195f0d86bee6759a33980db675c4ecaf2fcaa80fa125828e6cd38",
|
||||
"urls": [
|
||||
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/mdx_model_data/mdx_c_configs/vocals_mel_band_roformer.yaml",
|
||||
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/vocals_mel_band_roformer.yaml"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"model_bs_roformer_ep_317_sdr_12.9755.ckpt": {
|
||||
"recipe_version": 1,
|
||||
"publisher": "Viperx / UVR",
|
||||
"homepage": "https://github.com/nomadkaraoke/python-audio-separator",
|
||||
"files": [
|
||||
{
|
||||
"file": "model_bs_roformer_ep_317_sdr_12.9755.ckpt",
|
||||
"sha256": "5b84f37e8d444c8cb30c79d77f613a41c05868ff9c9ac6c7049c00aefae115aa",
|
||||
"urls": [
|
||||
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/model_bs_roformer_ep_317_sdr_12.9755.ckpt",
|
||||
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/model_bs_roformer_ep_317_sdr_12.9755.ckpt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "model_bs_roformer_ep_317_sdr_12.9755.yaml",
|
||||
"sha256": "2bfdd16c656bd9519aba757cc4f8834b7ede675eb1e00ec4772d74ae1c41af7f",
|
||||
"urls": [
|
||||
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/mdx_model_data/mdx_c_configs/model_bs_roformer_ep_317_sdr_12.9755.yaml",
|
||||
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/model_bs_roformer_ep_317_sdr_12.9755.yaml"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"htdemucs_ft.yaml": {
|
||||
"recipe_version": 1,
|
||||
"publisher": "Meta / Demucs",
|
||||
"homepage": "https://github.com/facebookresearch/demucs",
|
||||
"files": [
|
||||
{
|
||||
"file": "htdemucs_ft.yaml",
|
||||
"sha256": "69470b8c1bbd674437b51bc9fb491327a10ab0396b702c93389b9cf750016346",
|
||||
"urls": [
|
||||
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/htdemucs_ft.yaml",
|
||||
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/htdemucs_ft.yaml"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "f7e0c4bc-ba3fe64a.th",
|
||||
"sha256": "ba3fe64ae8ef66ac9a4857222ce48efbdc5eb3ad375cb79dd13debee5aaa4066",
|
||||
"urls": [
|
||||
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/f7e0c4bc-ba3fe64a.th"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "d12395a8-e57c48e6.th",
|
||||
"sha256": "e57c48e6b0e38af4f7118d7bd08c49f0a0c0edf7d09143bdd902ea0d237303e6",
|
||||
"urls": [
|
||||
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/d12395a8-e57c48e6.th"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "92cfc3b6-ef3bcb9c.th",
|
||||
"sha256": "ef3bcb9c8b40d14ae5d51b6db2587339cc12c6b77c0be151ce6d69002e087bf2",
|
||||
"urls": [
|
||||
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/92cfc3b6-ef3bcb9c.th"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "04573f0d-f3cf25b2.th",
|
||||
"sha256": "f3cf25b222c4eed7cd49dd8b2c9597d50c18bd154090f7b919cfa5f93cf22c49",
|
||||
"urls": [
|
||||
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/04573f0d-f3cf25b2.th"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"htdemucs_6s.yaml": {
|
||||
"recipe_version": 1,
|
||||
"publisher": "Meta / Demucs",
|
||||
"homepage": "https://github.com/facebookresearch/demucs",
|
||||
"files": [
|
||||
{
|
||||
"file": "htdemucs_6s.yaml",
|
||||
"sha256": "207405151270af8fd81c2373c25d27950916682ac91dca7884a11ce13dad6f58",
|
||||
"urls": [
|
||||
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/htdemucs_6s.yaml",
|
||||
"https://github.com/nomadkaraoke/python-audio-separator/releases/download/model-configs/htdemucs_6s.yaml"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "5c90dfd2-34c22ccb.th",
|
||||
"sha256": "34c22ccb381c6f9fdbf324f04e1e2fe21aaaf293f5ded163a162697ff9a02ddd",
|
||||
"urls": [
|
||||
"https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/5c90dfd2-34c22ccb.th"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""One native separation process; exact pinned package, no arbitrary model code."""
|
||||
import json,sys
|
||||
from pathlib import Path
|
||||
from audio_separator.separator import Separator
|
||||
import torch
|
||||
model_dir,model,input_file,output_dir=sys.argv[1:5]
|
||||
target=sys.argv[5] if len(sys.argv)>5 else ''
|
||||
from separator_runtime import verify_package
|
||||
if not verify_package(model_dir,model):raise RuntimeError('Modellpaket-Prüfsumme stimmt nicht.')
|
||||
if not torch.cuda.is_available():raise RuntimeError('CUDA nicht verfügbar; kein stiller CPU-Fallback.')
|
||||
separator=Separator(model_file_dir=model_dir,output_dir=output_dir,output_format='WAV',use_soundfile=True,demucs_params={'segment_size':7,'shifts':2,'overlap':.25},mdxc_params={'segment_size':256,'override_model_segment_size':False,'batch_size':1,'overlap':2,'pitch_shift':0})
|
||||
separator.load_model(model)
|
||||
files=separator.separate(input_file)
|
||||
root=Path(output_dir).resolve();names=[]
|
||||
for name in files:
|
||||
path=(root/name).resolve()
|
||||
if path.parent!=root or not path.is_file() or path.suffix.lower()!='.wav':raise RuntimeError('Ungültige Ausgabe.')
|
||||
names.append(path.name)
|
||||
if not names:raise RuntimeError('Keine Spuren erzeugt.')
|
||||
if target:
|
||||
import subprocess
|
||||
chosen=next((n for n in names if target in Path(n).stem.lower()),None)
|
||||
if not chosen:raise RuntimeError('Zielspur fehlt.')
|
||||
others=[root/n for n in names if n!=chosen]
|
||||
if not others:raise RuntimeError('Restspuren fehlen.')
|
||||
args=['ffmpeg','-hide_banner','-loglevel','error','-y']
|
||||
for f in others:args+=['-i',str(f)]
|
||||
args+=['-filter_complex',''.join(f'[{i}:a]' for i in range(len(others)))+f'amix=inputs={len(others)}:normalize=0:dropout_transition=0[rest]','-map','[rest]','-c:a','pcm_s16le',str(root/'rest.wav')]
|
||||
subprocess.run(args,check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=180)
|
||||
names=[chosen,'rest.wav']
|
||||
(root/'results.json').write_text(json.dumps(names))
|
||||
@@ -17,6 +17,8 @@ from profiles import Profiles
|
||||
from capacity import assess, overview
|
||||
from runtime import Runtime
|
||||
from image_runtime import ImageRuntime
|
||||
from separator_runtime import SeparatorRuntime
|
||||
from separator import SeparatorTests
|
||||
from image_test import ImageTests
|
||||
from prompt_enhancer import PromptEnhancer
|
||||
from docker_support import DockerSupport
|
||||
@@ -99,6 +101,8 @@ class Server(ThreadingHTTPServer):
|
||||
raise RuntimeError('Serverzugang muss vor dem Start eingerichtet werden.')
|
||||
self.worker=LlamaWorker(self.catalog.root.parent/'llama-worker',self.catalog,self.runtime)
|
||||
self.scheduler=Scheduler(self.worker)
|
||||
self.separator_runtime=SeparatorRuntime(self.catalog.root.parent/'separator-runtime')
|
||||
self.separator_tests=SeparatorTests(self.catalog.root.parent/'separator-tests',self.separator_runtime,self.scheduler)
|
||||
self.profiles.chat_blockers=self.worker.blockers
|
||||
self.image_tests.acquire=self.scheduler.image_reservation
|
||||
self.prompt_enhancer.acquire=self.scheduler.image_reservation
|
||||
@@ -112,12 +116,22 @@ class Server(ThreadingHTTPServer):
|
||||
self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler)
|
||||
self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
|
||||
def stop_gpu_work():
|
||||
self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop()
|
||||
self.separator_tests.stop();self.music.stop();self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop()
|
||||
from video_original import VideoRuntimes
|
||||
from ltx_original_runtime import LTXOriginalRuntime
|
||||
from audio_cpp_runtime import AudioCppRuntime
|
||||
self.audio_cpp_runtime=AudioCppRuntime(self.catalog.root.parent/'audio-cpp-runtime')
|
||||
self.profiles.music_runtime_ready=lambda:self.audio_cpp_runtime.status()['installed']
|
||||
from music import Music
|
||||
self.music=Music(self.catalog.root.parent/'music-jobs',self.profiles,self.catalog,self.audio_cpp_runtime,self.scheduler)
|
||||
self.profiles.music_blockers=self.music.blockers
|
||||
self.endpoint.music=self.music
|
||||
from profiles import audio_package_recipe
|
||||
self.profiles.runtime_recipe=lambda model:audio_package_recipe(model,self.audio_cpp_runtime.paths()[1])
|
||||
self.ltx_original_runtime=LTXOriginalRuntime(self.catalog.root.parent/'ltx-original-runtime',self.image_runtime)
|
||||
self.video=VideoRuntimes(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime,original_runtime=self.ltx_original_runtime)
|
||||
self.endpoint.video=self.video
|
||||
self.video.music_profiles=lambda:[dict(id=p['id'],name=p['name']) for p in self.endpoint.rows() if p['kind']=='music' and p['enabled'] and p['runnable']]
|
||||
self.profiles.video_runtime_ready=lambda:self.image_runtime.status().get("installed",False)
|
||||
from dashboard_data import Dashboard
|
||||
self.dashboard=Dashboard(self,self.catalog.root.parent)
|
||||
@@ -173,7 +187,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return secrets.compare_digest(actual,record['api_token_hash'])
|
||||
|
||||
def token_route(self):
|
||||
return self.command == 'GET' and self.path in ('/api/v1/status','/api/v1/hardware')
|
||||
return (self.command == 'GET' and (self.path in ('/api/v1/status','/api/v1/hardware','/api/v1/separator-runtime','/api/v1/separator-tests') or urlsplit(self.path).path=='/api/v1/separator-tests/audio')) or (self.command=='POST' and self.path in ('/api/v1/separator-jobs/start','/api/v1/separator-tests/cancel'))
|
||||
|
||||
def session_token(self):
|
||||
try:
|
||||
@@ -277,9 +291,21 @@ class Handler(BaseHTTPRequestHandler):
|
||||
allowed.update(filter(None,os.environ.get('DECK_ALLOWED_HOSTS','').split(',')))
|
||||
return self.headers.get('Host') in allowed
|
||||
|
||||
def execution_setup(self, model, profile=None):
|
||||
installed={'stt':bool(self.server.runtime.status().get('active')),'chat':bool(self.server.runtime.status().get('active')),'image':self.server.image_runtime.status().get('installed',False),'audio':self.server.tts_runtime.status().get('installed',False),'video':self.server.video.status()['runtime']['installed']}
|
||||
return execution_assess(model,installed,profile)
|
||||
def execution_runtime_snapshot(self):
|
||||
# Installation checks need no video model/profile scan or worker status.
|
||||
chat = bool(self.server.runtime.status().get('active'))
|
||||
video_runtime = (self.server.video.original_runtime
|
||||
if self.server.video.runtime_kind() == 'original'
|
||||
else self.server.video.runtime)
|
||||
return {'stt':chat,'chat':chat,
|
||||
'image':self.server.image_runtime.status().get('installed',False),
|
||||
'audio':self.server.tts_runtime.status().get('installed',False),
|
||||
'video':video_runtime.status().get('installed',False),
|
||||
'audio_cpp':self.server.audio_cpp_runtime.status()['installed'],
|
||||
'music_worker':True,'separator':self.server.separator_runtime.status()['installed']}
|
||||
|
||||
def execution_setup(self, model, profile=None, installed=None):
|
||||
return execution_assess(model, self.execution_runtime_snapshot() if installed is None else installed, profile)
|
||||
|
||||
def do_GET(self):
|
||||
if not self.valid_host():
|
||||
@@ -292,7 +318,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||
if not self.authenticated() and self.path == '/':
|
||||
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
||||
routes = {'/ltx-original-ui.js':('ltx-original-ui.js','text/javascript'),'/backup-ui.js':('backup-ui.js','text/javascript'),'/dashboard-ui.js':('dashboard-ui.js','text/javascript'),'/dashboard.css':('dashboard.css','text/css'),'/themes.css':('themes.css','text/css'),'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
|
||||
routes = {'/separator-ui.js':('separator-ui.js','text/javascript'),'/music-ui.js':('music-ui.js','text/javascript'),'/audio-cpp-ui.js':('audio-cpp-ui.js','text/javascript'),'/ltx-original-ui.js':('ltx-original-ui.js','text/javascript'),'/backup-ui.js':('backup-ui.js','text/javascript'),'/dashboard-ui.js':('dashboard-ui.js','text/javascript'),'/dashboard.css':('dashboard.css','text/css'),'/themes.css':('themes.css','text/css'),'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
|
||||
if self.path in routes:
|
||||
name, mime = routes[self.path]
|
||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
||||
@@ -311,6 +337,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if urlsplit(self.path).path == '/api/v1/tts/audio':
|
||||
try:return self.respond(self.server.tts_tests.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
|
||||
except (OSError,ValueError):return self.respond({'error':'Audio nicht verfügbar.'},404)
|
||||
if self.path == '/api/v1/music':return self.respond(self.server.music.status())
|
||||
if urlsplit(self.path).path == '/api/v1/music/audio':
|
||||
try:return self.respond(self.server.music.audio(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='audio/wav')
|
||||
except (OSError,ValueError):return self.respond({'error':'Musik nicht verfügbar.'},404)
|
||||
if self.path == '/api/v1/separator-runtime':return self.respond(self.server.separator_runtime.status())
|
||||
if self.path == '/api/v1/separator-runtime/log':return self.respond(self.server.separator_runtime.log())
|
||||
if self.path == '/api/v1/separator-tests':return self.respond(self.server.separator_tests.status())
|
||||
if urlsplit(self.path).path == '/api/v1/separator-tests/audio':
|
||||
try:
|
||||
query=parse_qs(urlsplit(self.path).query);return self.respond(self.server.separator_tests.audio(query.get('id',[''])[0],int(query.get('index',['-1'])[0])),mime='audio/wav')
|
||||
except (ValueError,OSError):return self.respond({'error':'Spur nicht verfügbar.'},404)
|
||||
if self.path == '/api/v1/audio-cpp-runtime':return self.respond(self.server.audio_cpp_runtime.status())
|
||||
if self.path == '/api/v1/audio-cpp-runtime/log':return self.respond(self.server.audio_cpp_runtime.log())
|
||||
if self.path == '/api/v1/ltx-original-runtime':return self.respond(self.server.ltx_original_runtime.status())
|
||||
if self.path == '/api/v1/ltx-original-runtime/log':return self.respond(self.server.ltx_original_runtime.log())
|
||||
if self.path == '/api/v1/video':return self.respond(self.server.video.status())
|
||||
@@ -321,12 +360,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:return self.respond(self.server.image_tests.image(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='image/png')
|
||||
except (OSError,ValueError):return self.respond({'error':'Bild nicht verfügbar.'},404)
|
||||
if urlsplit(self.path).path == '/api/v1/profiles/components':
|
||||
try:return self.respond(self.server.profiles.components(parse_qs(urlsplit(self.path).query).get('model_id',[''])[0]))
|
||||
try:return self.respond(self.server.profiles.components(parse_qs(urlsplit(self.path).query).get('model_id',[''])[0],parse_qs(urlsplit(self.path).query).get('repo',[None])[0]))
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
except Exception:return self.respond({'error':'Komponentenquelle nicht erreichbar. Bitte erneut versuchen.'},503)
|
||||
if self.path == '/api/v1/profiles':
|
||||
data=self.server.profiles.status()
|
||||
for p in data['profiles']:p['execution']=self.execution_setup(p.get('model') or {},p)
|
||||
installed=self.execution_runtime_snapshot()
|
||||
for p in data['profiles']:p['execution']=self.execution_setup(p.get('model') or {},p,installed)
|
||||
return self.respond(data)
|
||||
if self.path == '/api/v1/huggingface':return self.respond(self.server.catalog.hub_auth.status())
|
||||
if self.path == '/api/v1/hardware':
|
||||
@@ -366,12 +406,21 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.respond(overview(data['files'],self.server.hardware.snapshot()))
|
||||
if path.path=='/api/v1/catalog/files':
|
||||
data=dict(self.server.catalog.files(q.get('repo',[''])[0]));hardware=self.server.hardware.snapshot()
|
||||
data['files']=[dict(f,assessment=assess(f['size'],hardware,f['name']),execution=self.execution_setup(dict(repo=data['repo'],revision=data['revision'],file=f['name'],kind=q.get('kind',['chat'])[0],runtime_metadata=data.get('runtime_metadata',{})))) for f in data['files']]
|
||||
installed=self.execution_runtime_snapshot()
|
||||
data['files']=[dict(f,assessment=assess(f['size'],hardware,f['name']),execution=self.execution_setup(dict(repo=data['repo'],revision=data['revision'],file=f['name'],kind=q.get('kind',['chat'])[0],runtime_metadata=data.get('runtime_metadata',{})),installed=installed)) for f in data['files']]
|
||||
from profiles import component_recipe
|
||||
from catalog import file_role
|
||||
for f in data['files']:
|
||||
model=dict(repo=data['repo'],file=f['name'],kind=q.get('kind',['chat'])[0])
|
||||
recipe=component_recipe(model) or self.server.profiles.runtime_recipe(model)
|
||||
if recipe:f['execution']['components']=[r['label'] for r in recipe.values()]
|
||||
elif f['execution']['state']!='component':f['execution']['suggested_components']=[x['name'] for x in data['files'] if file_role(x['name'])['role'] in ('text_encoder','vae','vision_projector','audio_projector')][:12]
|
||||
return self.respond(data)
|
||||
if path.path=='/api/v1/catalog':
|
||||
data=self.server.catalog.status()
|
||||
installed=self.execution_runtime_snapshot()
|
||||
for entry in data['entries']:
|
||||
entry['execution']=self.execution_setup(entry)
|
||||
entry['execution']=self.execution_setup(entry,installed=installed)
|
||||
entry['used_by_profiles']=self.server.profiles.references(entry['id'])
|
||||
return self.respond(data)
|
||||
except Exception as exc:
|
||||
@@ -450,6 +499,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
with self.server.auth_lock:
|
||||
self.server.sessions.pop(self.session_token(),None)
|
||||
return self.respond({'logged_out':True})
|
||||
if self.path in ('/api/v1/docker/start','/api/v1/docker/stop','/api/v1/docker/delete'):
|
||||
try:
|
||||
data=self.read_json();action=self.path.rsplit('/',1)[1]
|
||||
expected={'id','confirm'} if action=='delete' else {'id'}
|
||||
if set(data)!=expected or (action=='delete' and data['confirm'] is not True):raise ValueError('Ungültige oder unbestätigte Containeraktion.')
|
||||
return self.respond(self.server.docker.container_action(action,data['id']))
|
||||
except (OSError,ValueError) as exc:return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht erreichbar.'},400)
|
||||
if self.path == '/api/v1/docker/install':
|
||||
try:
|
||||
if self.read_json()!={'confirm':True}:raise ValueError('Die Docker-Erstinstallation muss ausdrücklich bestätigt werden.')
|
||||
@@ -482,6 +538,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if data:raise ValueError('Keine Parameter erwartet.')
|
||||
return self.respond(self.server.chat_tests.stop() if self.path.endswith('/cancel') else self.server.chat_tests.unload())
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
if self.path in ('/api/v1/separator-runtime/install','/api/v1/separator-runtime/cancel','/api/v1/separator-runtime/download'):
|
||||
try:
|
||||
data=self.read_json()
|
||||
if self.path.endswith('/download'):
|
||||
if set(data)!={'model'}:raise ValueError('Nur Modell-ID erwartet.')
|
||||
return self.respond(self.server.separator_runtime.download(data['model']))
|
||||
if data:raise ValueError('Keine Parameter erwartet.')
|
||||
return self.respond(self.server.separator_runtime.stop() if self.path.endswith('/cancel') else self.server.separator_runtime.start())
|
||||
except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400)
|
||||
if self.path in ('/api/v1/separator-tests/start','/api/v1/separator-jobs/start','/api/v1/separator-tests/cancel'):
|
||||
try:
|
||||
if self.path.endswith('/cancel'):
|
||||
if self.read_json():raise ValueError('Keine Parameter erwartet.')
|
||||
return self.respond(self.server.separator_tests.stop())
|
||||
# Reuse bounded multipart parsing; separation permits stereo WAV.
|
||||
fields,audio=read_upload(self,validate=False,max_audio=256*1024**2 if self.path=='/api/v1/separator-jobs/start' else 8*1024**2)
|
||||
if not {'model'}<=set(fields) or set(fields)-{'model','target'}:raise ValueError('Nur Modell, Zielspur und WAV-Datei erwartet.')
|
||||
return self.respond(self.server.separator_tests.start(fields['model'],audio,target=fields.get('target'),full=self.path=='/api/v1/separator-jobs/start'))
|
||||
except (ValueError,OSError) as exc:return self.respond({'error':str(exc)},400)
|
||||
if self.path in ('/api/v1/audio-cpp-runtime/install','/api/v1/audio-cpp-runtime/cancel'):
|
||||
try:
|
||||
if self.read_json():raise ValueError('Keine Installationsparameter erwartet.')
|
||||
return self.respond(self.server.audio_cpp_runtime.stop() if self.path.endswith('/cancel') else self.server.audio_cpp_runtime.start())
|
||||
except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400)
|
||||
if self.path in ('/api/v1/ltx-original-runtime/install','/api/v1/ltx-original-runtime/cancel'):
|
||||
try:
|
||||
if self.read_json():raise ValueError('Keine Installationsparameter erwartet.')
|
||||
@@ -493,7 +573,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
data=self.read_json()
|
||||
if self.path.endswith('/runtime') and set(data)=={'runtime'}:return self.respond(self.server.video.select_runtime(data['runtime']))
|
||||
if self.path.endswith('/service') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id']))
|
||||
if set(data)!={'mode'}:raise ValueError('Nur Modus llm oder video erwartet.')
|
||||
if set(data)!={'mode'}:raise ValueError('Nur Modus llm, video oder music erwartet.')
|
||||
return self.respond(self.server.video.switch(data['mode']))
|
||||
except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400)
|
||||
if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'):
|
||||
@@ -517,6 +597,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.respond(self.server.stt.setup() if self.path.endswith('/setup') else self.server.stt.stop())
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
except Exception:return self.respond({'error':'STT-Aktion fehlgeschlagen.'},503)
|
||||
if self.path in ('/api/v1/music/start','/api/v1/music/cancel'):
|
||||
try:
|
||||
data=self.read_json()
|
||||
if self.path.endswith('/cancel') and not data:return self.respond(self.server.music.stop())
|
||||
if set(data)-{'profile_id','lyrics','style','seed','max_tokens','steps'} or not {'profile_id','lyrics','style'}<=set(data):raise ValueError('Ungültige Musikfelder.')
|
||||
return self.respond(self.server.music.start(**data))
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
if self.path in ('/api/v1/tts/install','/api/v1/tts/install-cancel','/api/v1/tts/assign','/api/v1/tts/start','/api/v1/tts/cancel'):
|
||||
try:
|
||||
data=self.read_json();t=self.server.tts_tests
|
||||
@@ -658,6 +745,7 @@ def main():
|
||||
server.tts_runtime.stop()
|
||||
server.image_runtime.stop()
|
||||
server.ltx_original_runtime.stop()
|
||||
server.separator_tests.stop();server.separator_runtime.stop();server.music.stop();server.audio_cpp_runtime.stop()
|
||||
server.prompt_enhancer.stop()
|
||||
server.image_tests.stop()
|
||||
server.runtime.stop()
|
||||
|
||||
@@ -22,13 +22,13 @@ def validate_wav(audio):
|
||||
if len(w.readframes(w.getnframes()))!=w.getnframes()*2:raise ValueError('WAV-Datei ist unvollständig.')
|
||||
except (wave.Error,EOFError):raise ValueError('Ungültige WAV-Datei.') from None
|
||||
|
||||
def read_upload(handler):
|
||||
def read_upload(handler,validate=True,max_audio=MAX_AUDIO):
|
||||
handler.connection.settimeout(30)
|
||||
if handler.headers.get('Transfer-Encoding'):raise ValueError('Chunked Upload wird nicht unterstützt.')
|
||||
try:length=int(handler.headers.get('Content-Length','0'))
|
||||
except ValueError:raise ValueError('Ungültige Upload-Länge.') from None
|
||||
content_type=handler.headers.get('Content-Type','')
|
||||
if not 0<length<=MAX_AUDIO+65536 or not content_type.lower().startswith('multipart/form-data;'):raise ValueError('Multipart-Upload bis 8 MiB erforderlich.')
|
||||
if not 0<length<=max_audio+65536 or not content_type.lower().startswith('multipart/form-data;'):raise ValueError('Multipart-Upload bis 8 MiB erforderlich.')
|
||||
raw=handler.rfile.read(length)
|
||||
if len(raw)!=length:raise ValueError('Upload unvollständig.')
|
||||
message=BytesParser(policy=policy.default).parsebytes(b'Content-Type: '+content_type.encode()+b'\r\nMIME-Version: 1.0\r\n\r\n'+raw)
|
||||
@@ -41,10 +41,10 @@ def read_upload(handler):
|
||||
if audio is not None:raise ValueError('Nur eine Audiodatei erlaubt.')
|
||||
audio=data
|
||||
else:
|
||||
if name not in ('model','profile_id','language','response_format') or name in fields or len(data)>256:raise ValueError('Ungültiges oder doppeltes Feld.')
|
||||
if name not in ('model','profile_id','language','response_format','target') or name in fields or len(data)>256:raise ValueError('Ungültiges oder doppeltes Feld.')
|
||||
try:fields[name]=data.decode('utf-8')
|
||||
except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None
|
||||
validate_wav(audio)
|
||||
if validate:validate_wav(audio)
|
||||
if fields.get('response_format','json')!='json':raise ValueError('Aktuell wird response_format=json unterstützt.')
|
||||
return fields,audio
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
/* Server-backed model management. Legacy browser drafts remain untouched. */
|
||||
const Studio=(()=>{
|
||||
let section='discover',category='';
|
||||
const labels={chat:'Sprachmodelle / Chat',image:'Bildgenerierung',audio:'Text-to-Speech (TTS)',stt:'Spracherkennung (STT)',music:'Musik',voice:'Stimme / Voice',video:'Video'};
|
||||
const audioDescriptions={audio:'Geschriebenen Text vorlesen lassen. Die Suche zeigt Text-to-Speech-Modelle.',stt:'Gesprochene Sprache in Text umwandeln. Die Suche zeigt Spracherkennungsmodelle.',music:'Musik und Klänge aus Text erzeugen. Die Hub-Kategorie Text-to-Audio umfasst neben Musik auch Geräusche und andere Audioerzeugung.',voice:'Vorhandenes Audio bearbeiten: Stimmumwandlung, Audiotrennung und weitere Audio-to-Audio-Modelle. Eigenständige Studio-Oberflächen gehören unter Weitere Dienste.'};
|
||||
const labels={chat:'Chat & Sprachmodelle',image:'Bildgenerierung',audio:'Sprachausgabe (TTS)',stt:'Spracherkennung (STT)',music:'Musikgenerierung',voice:'Stimmwerkzeuge',video:'Videogenerierung'};
|
||||
const audioDescriptions={audio:'Geschriebenen Text vorlesen lassen. Die Suche zeigt Text-to-Speech-Modelle.',stt:'Gesprochene Sprache in Text umwandeln. Die Suche zeigt Spracherkennungsmodelle.',music:'Musik und Klänge aus Text erzeugen. Die Hub-Kategorie Text-to-Audio umfasst neben Musik auch Geräusche und andere Audioerzeugung.',voice:'Vorhandenes Audio bearbeiten: Stimmumwandlung, Audio-Trennung und weitere Audio-to-Audio-Modelle. Eigenständige Studio-Oberflächen gehören unter Weitere Dienste.'};
|
||||
function render(page,force=false,modelId=null){
|
||||
if(page==='video'){VideoUI.runtime();return;}
|
||||
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
||||
if(category!==page){category=page;section='discover';}
|
||||
if(modelId)section='profiles';
|
||||
const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${audioDescriptions[page]&&!['audio','stt'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
||||
const body=page==='stt-runtime'?STTUI.html(true):page==='tts-runtime'?TTSUI.html(true):['docker-runtime','services'].includes(page)?DockerUI.html(page==='services'):page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>${audioDescriptions[page]||'Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.'}</p>${page==='voice'?'<p><a class="link" href="#separator-runtime">Audio Separator einrichten →</a> · <a class="link" href="#separation">Audio-Trennung →</a></p><p class="note">Audio-Trennung nutzt die unterstützten Pakete im Audio-Separator-Bereich. Andere Hub-Dateien und Voice-Profile benötigen weiterhin eine geprüfte Anbindung.</p>':''}${audioDescriptions[page]&&!['audio','stt','music','voice'].includes(page)?'<p class="note">Entdecken und Downloads sind verfügbar. Für diese Audio-Bereiche ist noch keine ausführbare Laufzeit in Deck angebunden. Gespeicherte Profile sind vorbereitend.</p>':''}<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(['audio','stt'].includes(page)?[['setup','Einrichten']]:[]),...(['image','chat','audio','stt','music'].includes(page)?[['test','Testen'],...(page==='chat'?[['auto','Auto-Test']]:[])]:[])].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='setup'?(page==='stt'?STTUI.html(true):TTSUI.html(true)):['test','running'].includes(section)&&page==='music'?MusicUI.html():['test','running'].includes(section)&&page==='stt'?STTUI.html():['test','running'].includes(section)&&page==='audio'?TTSUI.html():section==='auto'?AutoTestUI.html():section==='test'?(page==='chat'?ChatTestUI.html():ImageTestUI.html()):section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'&&page==='chat'?EndpointUI.runningHTML():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
|
||||
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
|
||||
if(['docker-runtime','services'].includes(page)){DockerUI.bind(page==='services');return;}
|
||||
if(page==='stt-runtime'){STTUI.bind(true);return;}
|
||||
if(page==='tts-runtime'){TTSUI.bind(true);return;}
|
||||
if(page==='runtime'){RuntimeUI.bind();return;}
|
||||
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
|
||||
if(page==='runtimes')return;
|
||||
if(page==='runtimes'){bindRuntimeOverview();return;}
|
||||
else if(section==='setup'){if(page==='stt')STTUI.bind(true);else TTSUI.bind(true);}
|
||||
else if(['test','running'].includes(section)&&page==='music')MusicUI.bind();
|
||||
else if(['test','running'].includes(section)&&page==='stt')STTUI.bind();
|
||||
else if(['test','running'].includes(section)&&page==='audio')TTSUI.bind();
|
||||
else if(section==='auto')AutoTestUI.bind();
|
||||
@@ -27,7 +28,32 @@ const Studio=(()=>{
|
||||
else if(section!=='running')CatalogUI.bind(page,section==='library',section==='downloads');
|
||||
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 werden einmal eingerichtet und für kompatible Modelle wiederverwendet. Entdecken, Bibliothek und Profile zeigen den passenden Einrichtungsweg; unbekannte Modelle werden als noch nicht unterstützt gekennzeichnet. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>Spracherkennung</h2><p>Qwen3-ASR mit llama.cpp und Audio-Projektor auf der CPU.</p><a class="link" href="#stt-runtime">STT einrichten →</a></section><section class="card"><h2>Text-to-Speech</h2><p>Qwen3-TTS mit CUDA und eingebauten Stimmen einrichten.</p><a class="link" href="#tts-runtime">TTS einrichten →</a></section><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><section class="card"><h2>LTX Original</h2><p>Originales LTX-Desktop-Backend mit nativer LTX-API für LTX DeskWEB.</p><a class="link" href="#ltx-original-runtime">LTX Original einrichten →</a></section><section class="card"><h2>Docker</h2><p>Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.</p><a class="link" href="#docker-runtime">Docker öffnen →</a></section></div>`;}
|
||||
const runtimeRows=[
|
||||
['llama.cpp','Chat & Sprachmodelle','runtime','/api/v1/runtime','Offizielle Releases prüfen, Änderungen lesen, CPU-/CUDA-Build erstellen und geprüften Standard auswählen.'],
|
||||
['ComfyUI + GGUF','Bilder & Video','image-runtime','/api/v1/image-runtime','Einmal installieren und für unterstützte Bild- und Videomodelle wiederverwenden. Gepinnte Versionen werden mit Deck gepflegt.'],
|
||||
['Qwen3-TTS','Sprachausgabe','tts-runtime','/api/v1/tts','CUDA-Laufzeit für unterstützte Qwen3-TTS-Modelle. Die Einrichtung lädt auch das vorgesehene Stimmenmodell.'],
|
||||
['Qwen3-ASR','Spracherkennung','stt-runtime','/api/v1/stt','Verwendet llama.cpp. Modell und Audio-Projektor werden in der Einrichtung zugeordnet.'],
|
||||
['audio.cpp','Musik & Audio','audio-cpp-runtime','/api/v1/audio-cpp-runtime','CUDA-Build einmal erstellen. YuE2 ist angebunden; andere Modellfamilien brauchen eine geprüfte Worker-Anbindung.'],
|
||||
['LTX Original','Video','ltx-original-runtime','/api/v1/ltx-original-runtime','Originales LTX-Backend für LTX DeskWEB. Modelle und Zusatzdateien bleiben in der Bibliothek.'],
|
||||
['Docker Engine','Weitere Dienste','docker-runtime','/api/v1/docker','Optionale Container-Umgebung. Erstinstallation über den Systemhelfer; vorhandenes Docker wird nicht automatisch aktualisiert.'],
|
||||
['Audio Separator','Audio-Trennung','separator-runtime','/api/v1/separator-runtime','Native CUDA-Laufzeit für BS-Roformer, MelBand und Demucs FT/6s. Vollständige Modellpakete mit überprüften Originalquellen und Prüfsummen.']
|
||||
];
|
||||
const runtimeEscape=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Einmal einrichten, für passende Modelle wiederverwenden. Dieser Katalog zeigt die in Deck unterstützten und vorgesehenen Laufzeiten. Zusatzdateien findest du beim Modell unter Einrichtung & Komponenten.</p><label>Laufzeit suchen<input id="runtime-search" type="search" placeholder="Name oder Bereich"></label><p id="runtime-catalog-message" role="status">Installationsstatus wird geprüft …</p><div class="detail-list">${runtimeRows.map(([name,category,route,path,description],i)=>`<details class="detail-row" data-runtime-row="${i}"><summary class="detail-summary"><strong>${name}</strong><span>${category}</span><span data-runtime-status>${path?'Wird geprüft …':'Noch nicht angebunden'}</span></summary><div class="detail-body"><p>${description}</p><p data-runtime-version></p>${route?`<a class="link" href="#${route}">${i===0?'Installation, Build & Updates öffnen': 'Einrichtung öffnen'} →</a>`:''}${i===0?'<p><button id="catalog-llama-updates">llama.cpp-Updates prüfen</button></p><div id="catalog-llama-releases"></div>':''}</div></details>`).join('')}</div>`;}
|
||||
function bindRuntimeOverview(){
|
||||
const root=document.querySelector('#studio[data-page="runtimes"]');if(!root)return;
|
||||
const esc=runtimeEscape,message=root.querySelector('#runtime-catalog-message');
|
||||
root.querySelector('#runtime-search').oninput=event=>{const query=event.target.value.trim().toLocaleLowerCase();root.querySelectorAll('[data-runtime-row]').forEach(row=>{const data=runtimeRows[Number(row.dataset.runtimeRow)];row.hidden=!data.slice(0,2).join(' ').toLocaleLowerCase().includes(query);});};
|
||||
async function read(path){const response=await fetch(path);const data=await response.json();if(!response.ok)throw Error(data.error||'Status nicht erreichbar');return data;}
|
||||
async function refresh(){const results=await Promise.allSettled(runtimeRows.map(async(row,i)=>{if(!row[3])return;const response=await read(row[3]),data=i===2?response.runtime:response;if(!root.isConnected)return;const el=root.querySelector(`[data-runtime-row="${i}"]`),active=i===0?data.builds?.find(b=>b.id===data.active):null;
|
||||
const installed=i===0?Boolean(active):data.installed;
|
||||
el.querySelector('[data-runtime-status]').textContent=data.job?.state==='running'?'Einrichtung läuft':installed===true?'Installiert':installed===false?'Einrichtung erforderlich':'Nicht ermittelbar';
|
||||
el.querySelector('[data-runtime-version]').textContent=[active?`${active.revision} · ${active.backend}`:data.version||data.comfy_revision||data.desktop_revision||data.revision||'',data.job?`${data.job.state} · ${data.job.phase||''}`:'',i===0?'Neue Versionen werden nur auf Knopfdruck geprüft.':''].filter(Boolean).join(' · ');
|
||||
}));if(!root.isConnected)return;results.forEach((result,i)=>{if(result.status==='rejected')root.querySelector(`[data-runtime-row="${i}"] [data-runtime-status]`).textContent='Status nicht erreichbar';});message.textContent=results.some(r=>r.status==='rejected')?'Einige Statusabfragen sind fehlgeschlagen. Einrichtung öffnen oder später erneut prüfen.':'Installationsstatus geprüft. Eine installierte Laufzeit benötigt weiterhin ein kompatibles Modell und vollständige Komponenten.';setTimeout(()=>{if(root.isConnected)refresh();},10000);}
|
||||
root.querySelector('#catalog-llama-updates').onclick=async event=>{const button=event.currentTarget;button.disabled=true;message.textContent='Offizielle llama.cpp-Releases werden geprüft …';try{const data=await read('/api/v1/runtime/releases');if(!root.isConnected)return;root.querySelector('#catalog-llama-releases').innerHTML=data.releases.map(release=>`<article><h3>${esc(release.tag)} · ${release.prerelease?'Vorabversion':'Reguläres Release'}</h3><p>${esc(release.published)}</p><details><summary>Änderungen anzeigen</summary><pre style="white-space:pre-wrap">${esc(release.notes)}</pre></details><button data-catalog-release="${esc(release.tag)}">Build vorbereiten</button></article>`).join('');root.querySelectorAll('[data-catalog-release]').forEach(b=>b.onclick=()=>{RuntimeUI.prepareBuild(b.dataset.catalogRelease);location.hash='runtime';});message.textContent='Releases geprüft. Build vorbereiten übernimmt die Version; der Build startet erst auf deinen Klick.';}catch(error){if(root.isConnected)message.textContent=error.message;}finally{button.disabled=false;}};
|
||||
refresh();
|
||||
}
|
||||
|
||||
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 und das unterstützte FLUX.2-Klein-9B-Modell. 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. Bei großen FLUX-Gewichten wird zusätzlich System-RAM genutzt. 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);}
|
||||
return {render,openProfile};
|
||||
|
||||
@@ -38,3 +38,13 @@ nav a.nav-sub{padding-left:28px;font-size:12px;color:var(--deck-tone-a4bda8, #a4
|
||||
.deck-preview{background:#101416}.deck-preview i{background:#181f21;border:1px solid #303a37}.deck-preview i:nth-child(2){border-top:5px solid #c5edaf}
|
||||
.classic-preview{background:radial-gradient(circle at 15% -10%,#183049 0,transparent 70%),#070b11}.classic-preview i{background:linear-gradient(145deg,#111b28,#0a1018);border:1px solid #213044}.classic-preview i:nth-child(2){border-top:5px solid #45d7ff}
|
||||
@media(max-width:700px){.theme-options{grid-template-columns:1fr}}
|
||||
|
||||
/* Compact, expandable detail lists share one interaction across model categories. */
|
||||
.detail-list{display:grid;gap:8px;margin-top:20px}.detail-row{border:1px solid var(--deck-tone-303a37,#303a37);border-radius:9px;background:var(--deck-tone-181f21,#181f21);overflow:hidden}.detail-summary{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1.3fr) minmax(110px,.7fr) minmax(140px,1fr);gap:18px;align-items:center;padding:18px 18px 18px 38px;position:relative;list-style:none;line-height:1.5}.detail-summary::-webkit-details-marker{display:none}.detail-summary:before{content:'▸';position:absolute;left:16px;top:19px;color:var(--deck-tone-c5edaf,#c5edaf)}.detail-row[open]>.detail-summary:before{content:'▾'}.detail-summary:hover{background:var(--deck-tone-20292a,#20292a)}.detail-summary:focus-visible{outline:2px solid var(--deck-tone-c5edaf,#c5edaf);outline-offset:-3px}.detail-summary strong{font-size:14px;overflow-wrap:anywhere}.detail-summary span{font-size:12px;color:var(--deck-tone-97a79e,#97a79e);overflow-wrap:anywhere}.detail-summary small{display:block;margin-top:4px}.detail-body{padding:8px 22px 22px;border-top:1px solid var(--deck-tone-303a37,#303a37)}.detail-body>.card-actions{margin-top:16px}.detail-body .execution-setup{margin-top:12px;padding-top:12px}.detail-row .endpoint-choice{margin:0 8px 8px 0;font-size:12px}#studio[data-page=runtimes] .detail-summary{grid-template-columns:1fr 1fr 1fr}.download-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 20px;padding:18px;margin-top:8px;border-radius:9px}.download-row .card-top,.download-row progress,.download-row .warning{grid-column:1/-1}.download-row h3,.download-row p{margin:0}.download-row h3{font-size:14px;overflow-wrap:anywhere}.download-row .hub-filename{grid-column:1/-1}.download-row button{margin:0;font-size:12px}.download-row .card-top{display:flex;justify-content:space-between}.download-row .card-top strong{font-size:14px}.nav-heading{margin:16px 12px 4px}.eyebrow{margin-top:24px}aside{padding-top:24px}nav a{padding:9px 12px}nav .nav-heading:first-child{margin-top:0}.studio-tabs{flex-wrap:wrap}
|
||||
@media(max-width:1000px){.detail-summary{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px 18px}}@media(max-width:600px){.detail-summary,#studio[data-page=runtimes] .detail-summary{grid-template-columns:1fr;gap:5px}.detail-body{padding:8px 16px 16px}.download-row{grid-template-columns:1fr}.download-row>*{grid-column:1!important}nav{grid-template-columns:1fr 1fr}nav .nav-heading{grid-column:1/-1}.card-actions{gap:6px}}
|
||||
|
||||
/* Control is a focused mode chooser; detailed API configuration lives in settings. */
|
||||
.control-modes{display:flex;gap:12px;margin:24px 0 20px}.control-modes button{min-width:110px;margin:0;font-size:15px}.control-card{max-width:900px}.control-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.control-heading h2{margin:0;font-size:25px}.control-card>.card-actions{margin-top:18px}.control-api{border-top:1px solid var(--deck-tone-303a37,#303a37);margin-top:24px;padding-top:8px}.control-api h3{font-size:14px}.control-api>div{display:grid;grid-template-columns:130px minmax(0,1fr);gap:14px;margin:12px 0;font-size:13px}.control-api span{color:var(--deck-tone-97a79e,#97a79e)}.control-api code{overflow-wrap:anywhere}.control-details{border-top:1px solid var(--deck-tone-303a37,#303a37);padding-top:16px;margin-top:20px}.control-card .small{font-size:12px}@media(max-width:600px){.control-modes{gap:8px}.control-modes button{min-width:0;flex:1;padding:12px}.control-api>div{grid-template-columns:1fr;gap:4px}}
|
||||
|
||||
.library-group + .library-group { margin-top: 1.5rem; }
|
||||
.library-group > h3 { margin: .5rem 0 .75rem; }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from audio_cpp_runtime import AudioCppRuntime,REVISION
|
||||
|
||||
class AudioCppTests(unittest.TestCase):
|
||||
def test_marker_cannot_escape_root(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
Path(root,'active.json').write_text(json.dumps({'id':'../../outside'}))
|
||||
self.assertFalse(AudioCppRuntime(root).status()['installed'])
|
||||
def test_build_failure_is_not_published(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
runtime=AudioCppRuntime(root);runtime.job=dict(id='a'*32,state='running')
|
||||
with patch.object(runtime,'_command',side_effect=RuntimeError('synthetic failure')):runtime._run()
|
||||
self.assertEqual(runtime.job['state'],'failed')
|
||||
self.assertFalse(runtime.status()['installed'])
|
||||
self.assertFalse(Path(root,'a'*32,'ready').exists())
|
||||
def test_publish_only_after_cli_smoke_and_spec(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
runtime=AudioCppRuntime(root);runtime.job=dict(id='a'*32,state='running');commands=[]
|
||||
def command(args,env):
|
||||
commands.append([str(a) for a in args])
|
||||
base=Path(root,'a'*32)
|
||||
if str(args[0])=='cmake' and '--build' in args:
|
||||
(base/'source/model_specs').mkdir(parents=True)
|
||||
(base/'source/model_specs/yue2.json').write_text('{}')
|
||||
(base/'build/bin').mkdir(parents=True)
|
||||
(base/'build/bin/audiocpp_cli').touch()
|
||||
with patch.object(runtime,'_command',side_effect=command),patch('subprocess.check_output',return_value='12.0\n8.6\n'):runtime._run()
|
||||
self.assertEqual(runtime.job['state'],'complete')
|
||||
self.assertTrue(runtime.status()['installed'])
|
||||
self.assertTrue(runtime.status()['adapter_ready'])
|
||||
self.assertIn('-DCMAKE_CUDA_ARCHITECTURES=120;86',commands[3])
|
||||
self.assertEqual(commands[-1][-1],'--help')
|
||||
self.assertEqual(Path(root,'a'*32,'ready').read_text(),REVISION)
|
||||
def test_missing_prerequisites_dont_start_thread(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
runtime=AudioCppRuntime(root)
|
||||
with patch('audio_cpp_runtime.sys.platform','linux'),patch('audio_cpp_runtime.shutil.which',return_value=None):
|
||||
with self.assertRaisesRegex(ValueError,'Build-Werkzeuge fehlen'):runtime.start()
|
||||
self.assertIsNone(runtime.worker)
|
||||
@@ -51,6 +51,14 @@ class RestoreTests(unittest.TestCase):
|
||||
doc=open_backup(self.server.backup.export('backup secure password'),'backup secure password')
|
||||
self.assertNotIn('PRIVATE-CHAT-LOG',json.dumps(doc));self.assertNotIn('model-weights-not-in-backup',json.dumps(doc));self.assertIn('history',doc);validate(doc)
|
||||
self.assertEqual(doc['models'][0]['id'],ident);self.assertEqual(len(doc['settings']['profiles.json']),1)
|
||||
def test_separator_restore_installs_runtime_and_redownloads_package(self):
|
||||
from separator_runtime import MODELS
|
||||
doc=self.server.backup.snapshot();name=next(iter(MODELS));doc['runtimes']['separator']={'installed':True,'models':[name]}
|
||||
runtime=Mock();runtime.job=None;runtime.status.return_value={'installed':False,'job':{'state':'complete'},'version':'0.47.0','models':[]};runtime.model_ready.return_value=False
|
||||
self.server.separator_runtime=runtime
|
||||
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.assertFalse(summary['blockers'])
|
||||
self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait()
|
||||
self.assertEqual(job['state'],'complete',job);runtime.start.assert_called_once();runtime.download.assert_called_once_with(name)
|
||||
def test_restore_reuses_verified_models_restores_profiles_and_keeps_login_when_selected(self):
|
||||
ident=self.model();self.profile(ident);raw=self.server.backup.export('backup secure password');self.server.profiles.rows=[];(self.root/'profiles.json').write_text('[]')
|
||||
current=self.server.credentials.read();summary=self.server.backup.inspect(raw,'backup secure password');self.assertFalse(summary['blockers'])
|
||||
@@ -87,6 +95,11 @@ class ContainerTests(unittest.TestCase):
|
||||
for change in (lambda x:x.update(name='other-ui'),lambda x:x['mounts'][0].update(deck_path='models'),lambda x:x['mounts'][0].update(deck_path='video/original-work'),lambda x:x['mounts'][0].update(target='/data/outputs')):
|
||||
bad=copy.deepcopy(c);change(bad)
|
||||
with self.assertRaises(ValueError):validate_container(bad)
|
||||
def test_ladypoly_recipe_and_config_are_scoped(self):
|
||||
c=self.config();c.update(name='athena-ladypoly',image='athena-ladypoly:3960afc-deck1',registry=None,network='host',build_recipe='ladypoly',mounts=[dict(index=0,target='/data',deck_path=None,read_only=False,was_file=False,files={'deck-connection.json':'e30='})]);validate_container(c)
|
||||
for change in (lambda x:x.update(name='other-ui'),lambda x:x.update(image='untrusted:1'),lambda x:x['mounts'][0].update(target='/other')):
|
||||
bad=copy.deepcopy(c);change(bad)
|
||||
with self.assertRaises(ValueError):validate_container(bad)
|
||||
def test_never_replaces_existing_foreign_container(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
manager=Mock(state=Path(d));(Path(d)/'backup-policy.json').write_text(json.dumps({'deck_state':d}))
|
||||
|
||||
@@ -21,6 +21,23 @@ class DockerTests(unittest.TestCase):
|
||||
manager=Manager(d)
|
||||
for data in ({'action':'exec','command':'id'},{'action':'stop'},[],{'action':'install','args':['--privileged']}):
|
||||
with self.assertRaises(ValueError):manager.dispatch(data)
|
||||
def test_container_actions_require_current_labelled_full_id(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
manager=Manager(d);ident='a'*64
|
||||
with patch.object(manager,'inventory',return_value=[]),patch('deploy.docker_helper.run') as run:
|
||||
with self.assertRaises(ValueError):manager.container_action(ident,'stop')
|
||||
run.assert_not_called()
|
||||
with patch.object(manager,'inventory',return_value=[dict(id=ident,state='running')]),patch.object(manager,'video_configs',return_value=[]),patch('deploy.docker_helper.run') as run:
|
||||
with self.assertRaises(ValueError):manager.container_action(ident,'delete')
|
||||
run.assert_not_called()
|
||||
with patch.object(manager,'inventory',return_value=[dict(id=ident,state='exited')]),patch.object(manager,'video_configs',return_value=[]),patch('deploy.docker_helper.run',return_value=Mock(returncode=0)) as run:
|
||||
for action,command in [('start',['start',ident]),('stop',['stop','--time','30',ident]),('delete',['rm',ident])]:
|
||||
self.assertTrue(manager.dispatch({'action':'container-'+action,'service':ident})['changed'])
|
||||
self.assertEqual(run.call_args.args[0],['/usr/bin/docker']+command)
|
||||
with patch.object(manager,'inventory',return_value=[dict(id=ident,state='exited')]),patch.object(manager,'video_configs',return_value=[dict(container_id=ident)]),patch('deploy.docker_helper.run') as run:
|
||||
with self.assertRaises(ValueError):manager.container_action(ident,'start')
|
||||
run.assert_not_called()
|
||||
|
||||
def test_existing_docker_is_never_reinstalled(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch('deploy.docker_helper.release',return_value={'ID':'debian','VERSION_ID':'13'}),patch('deploy.docker_helper.Path.exists',return_value=True),patch('deploy.docker_helper.threading.Thread') as thread:
|
||||
manager=Manager.__new__(Manager);manager.allow_install=True;manager.lock=__import__('threading').RLock();manager.job=None
|
||||
|
||||
@@ -61,6 +61,35 @@ class EndpointTests(unittest.TestCase):
|
||||
def request(self,path='/v1/models',data=None,token='A'*32):
|
||||
conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5);headers={'Content-Type':'application/json','Authorization':'Bearer '+token}
|
||||
conn.request('POST' if data is not None else 'GET',path,body=json.dumps(data) if data is not None else None,headers=headers);r=conn.getresponse();body=r.read();conn.close();return r.status,(body if data and data.get('stream') else json.loads(body))
|
||||
def test_status_allows_video_callback_to_read_endpoint_from_other_thread(self):
|
||||
finished=threading.Event();readers=[]
|
||||
def video_status():
|
||||
def read_profiles():
|
||||
self.ep.rows();finished.set()
|
||||
reader=threading.Thread(target=read_profiles,daemon=True);readers.append(reader);reader.start()
|
||||
self.assertTrue(finished.wait(1),'Endpoint lock blocks concurrent video/profile status queries')
|
||||
return {'service':{}}
|
||||
self.ep.video=Mock();self.ep.video.status.side_effect=video_status
|
||||
try:self.ep.status()
|
||||
finally:
|
||||
self.ep.video=None
|
||||
for reader in readers:reader.join(2)
|
||||
def test_music_discovery_and_authenticated_wav_route(self):
|
||||
self.rows.append(dict(id='music',name='Music',kind='music',runnable=True,blockers=[],parameters={},updated_at=1))
|
||||
self.ep.music=Mock();self.ep.music.start.return_value=dict(id='a'*32);self.ep.music.status.return_value=dict(job=dict(id='a'*32,state='complete'));self.ep.music.audio.return_value=b'RIFFsynthetic-WAV'
|
||||
self.enable('music')
|
||||
self.assertEqual(self.request('/v1/audio/music/models')[1]['data'][0]['id'],'Music')
|
||||
self.assertNotIn('Music',[x['id'] for x in self.request()[1]['data']])
|
||||
self.assertEqual(self.request('/v1/audio/music',dict(model='Music',lyrics='Synthetic',style='pop'))[0],503)
|
||||
self.ep.scheduler.gpu_mode='music'
|
||||
self.ep.video=Mock();self.ep.video.status.return_value={'service':{'ready':False}}
|
||||
self.assertEqual(self.request('/v1/audio/music/models')[1]['data'][0]['id'],'Music')
|
||||
self.assertEqual(self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='Synthetic')]))[0],503)
|
||||
conn=http.client.HTTPConnection('127.0.0.1',self.port,timeout=5)
|
||||
conn.request('POST','/v1/audio/music',json.dumps(dict(model='Music',lyrics='Synthetic',style='pop')),{'Content-Type':'application/json','Authorization':'Bearer '+'A'*32})
|
||||
response=conn.getresponse();self.assertEqual(response.status,200);self.assertEqual(response.getheader('Content-Type'),'audio/wav');self.assertEqual(response.read(),b'RIFFsynthetic-WAV');conn.close()
|
||||
self.assertEqual(self.request('/v1/audio/music',dict(model='Music',lyrics='Synthetic',style='pop',unsupported=True))[0],400)
|
||||
self.assertEqual(self.request('/v1/audio/music/models',token='bad')[0],401)
|
||||
def enable(self,name):self.ep.enable(dict(id=name,enabled=True))
|
||||
def test_explicit_publication_auth_rotation_and_missing_audio(self):
|
||||
self.assertEqual(self.request()[1]['data'],[]);self.assertEqual(self.request(token='bad')[0],401)
|
||||
|
||||
@@ -33,3 +33,21 @@ class ExecutionSetupTests(unittest.TestCase):
|
||||
self.assertEqual(result['family'],'NewImagePipeline')
|
||||
self.assertIn('Workflow',result['message'])
|
||||
self.assertEqual(assess(dict(model,kind='audio'),{'image':True})['state'],'unsupported')
|
||||
|
||||
def test_every_category_has_explicit_unverified_candidate(self):
|
||||
for kind in ('chat','image','audio','stt','music','voice','video'):
|
||||
result=self.check(kind,'unknown/family','weights.safetensors')
|
||||
self.assertTrue(result['candidates'])
|
||||
self.assertIn('NICHT bestätigt',result['candidates'][0]['evidence'])
|
||||
self.assertNotEqual(result['state'],'ready')
|
||||
def test_yue2_gguf_recognition_does_not_claim_worker(self):
|
||||
result=self.check('music','audio-cpp/Yue2-3B-GGUF','yue2-3b-q8_0.gguf',{'audio_cpp':True})
|
||||
self.assertEqual(result['family'],'YuE2')
|
||||
self.assertEqual(result['state'],'adapter_missing')
|
||||
self.assertTrue(result['candidates'][0]['installed'])
|
||||
self.assertEqual(result['candidates'][0]['route'],'#audio-cpp-runtime')
|
||||
self.assertTrue(result['components'])
|
||||
def test_component_never_offers_runtime_or_profile(self):
|
||||
result=self.check('music','audio-cpp/Yue2-3B-GGUF','yue2-vae-f16.gguf')
|
||||
self.assertEqual(result['state'],'component')
|
||||
self.assertEqual(result['candidates'],[])
|
||||
|
||||
+31
-3
@@ -19,11 +19,39 @@ class ImageTestTests(unittest.TestCase):
|
||||
p=dict(width=512,height=512,steps=4,guidance=1)
|
||||
graph=workflow('synthetic edit',p,42,references=['reference-1.png','reference-2.jpg'])
|
||||
self.assertEqual(graph['4']['inputs']['vae'],['3',0])
|
||||
self.assertEqual(graph['4']['inputs']['image_1'],['21',0])
|
||||
self.assertEqual(graph['4']['inputs']['image_2'],['22',0])
|
||||
self.assertEqual(graph['4']['inputs']['images.image_1'],['21',0])
|
||||
self.assertEqual(graph['4']['inputs']['images.image_2'],['22',0])
|
||||
self.assertEqual(graph['6']['inputs']['latent_image'],['4',2])
|
||||
self.assertEqual(graph['21']['inputs']['image'],'reference-1.png')
|
||||
with self.assertRaises(ValueError):workflow('test',p,1,'flux',['reference-1.png'])
|
||||
self.assertNotIn('image_1',graph['4']['inputs'])
|
||||
with self.assertRaises(ValueError):workflow('test',p,1,'unknown',['reference-1.png'])
|
||||
def test_flux_references_feed_both_conditioning_branches(self):
|
||||
p=dict(width=512,height=512,steps=4,guidance=1)
|
||||
for count in (1,4):
|
||||
graph=workflow('synthetic edit',p,42,'flux',[f'reference-{i}.png' for i in range(count)])
|
||||
self.assertEqual(sum(n['class_type']=='ReferenceLatent' for n in graph.values()),count*2)
|
||||
self.assertEqual(graph['10']['inputs']['positive'],[str(23+(count-1)*5),0])
|
||||
self.assertEqual(graph['10']['inputs']['negative'],[str(24+(count-1)*5),0])
|
||||
self.assertEqual(graph['22']['inputs']['vae'],['3',0])
|
||||
self.assertEqual(graph['6']['inputs']['latent_image'],['5',0])
|
||||
with self.assertRaises(ValueError):workflow('test',p,1,'flux',['r.png']*5)
|
||||
def test_reference_capabilities_follow_supported_recipes(self):
|
||||
from profiles import image_capabilities,FLUX_REPO,QWEN_REPO
|
||||
self.assertEqual(image_capabilities(dict(repo=FLUX_REPO,file='Flux.2 Klein-9B_fp16_nsfw.safetensors'))['reference_images'],4)
|
||||
self.assertEqual(image_capabilities(dict(repo=QWEN_REPO,file='model.gguf'))['reference_images'],4)
|
||||
self.assertEqual(image_capabilities(dict(repo='unknown',file='model.safetensors'))['reference_images'],0)
|
||||
def test_family_registry_reuses_workflow_from_lineage_not_filename(self):
|
||||
from profiles import image_adapter,image_recipe,FLUX_COMPONENTS
|
||||
m=dict(repo='someone/new-weights',file='custom.safetensors',runtime_metadata={'base_models':['black-forest-labs/FLUX.2-klein-9B']})
|
||||
self.assertEqual(image_adapter(m)['workflow'],'flux')
|
||||
self.assertEqual(image_recipe(m),FLUX_COMPONENTS)
|
||||
m['runtime_metadata']={};m['file']='flux-qwen-looking-name.safetensors'
|
||||
self.assertIsNone(image_adapter(m))
|
||||
m['runtime_metadata']={'architecture':'Flux2Transformer2DModel'}
|
||||
self.assertIsNone(image_adapter(m))
|
||||
m['runtime_metadata']={'architecture':'qwen_image_2_1'};m['file']='new.gguf'
|
||||
self.assertEqual(image_adapter(m)['workflow'],'qwen')
|
||||
m['file']='vae/new.gguf';self.assertIsNone(image_adapter(m))
|
||||
def test_reference_validation_enforces_profile_limit_and_signature(self):
|
||||
import base64
|
||||
png='data:image/png;base64,'+base64.b64encode(b'\x89PNG\r\n\x1a\nsynthetic').decode()
|
||||
|
||||
@@ -11,7 +11,7 @@ class ManagementTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name);self.model_id='a'*64
|
||||
target=self.root/'models'/self.model_id;target.mkdir(parents=True);(target/'model.gguf').write_bytes(b'GGUFtest')
|
||||
self.entry=dict(repo='test/Qwen-Image-2.1',file='test.gguf',kind='image',size=8,revision='b'*40,sha256=hashlib.sha256(b'GGUFtest').hexdigest(),downloaded_at=1)
|
||||
self.entry=dict(runtime_metadata={'architecture':'qwen_image_2_1'},repo='test/Qwen-Image-2.1',file='test.gguf',kind='image',size=8,revision='b'*40,sha256=hashlib.sha256(b'GGUFtest').hexdigest(),downloaded_at=1)
|
||||
(target/'entry.json').write_text(json.dumps(self.entry));self.catalog=Catalog(self.root/'models');self.profiles=Profiles(self.root/'profiles.json',self.catalog)
|
||||
def tearDown(self):self.tmp.cleanup()
|
||||
def request(self):return dict(id=None,revision=0,name='image-test',kind='image',model_id=self.model_id,parameters=dict(width=1024,height=1024,steps=25,seed=-1,guidance=1))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import tempfile,time,unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock,patch
|
||||
from music import Music
|
||||
from profiles import YUE2_COMPONENTS,YUE2_REPO
|
||||
class MusicTests(unittest.TestCase):
|
||||
def make(self,root):
|
||||
root=Path(root);catalog=Mock();catalog.root=root/'models';catalog.root.mkdir()
|
||||
main=dict(id='main',repo=YUE2_REPO,file='yue2-3b-bf16.gguf',kind='music',size=1)
|
||||
entries={role:dict(id=role,file=info['files'][0]) for role,info in YUE2_COMPONENTS.items()}
|
||||
for entry in [main]+list(entries.values()):
|
||||
(catalog.root/entry['id']).mkdir();(catalog.root/entry['id']/('model'+Path(entry['file']).suffix)).touch()
|
||||
catalog.entry.side_effect=lambda id:entries[id]
|
||||
p=dict(id='p',kind='music',runnable=True,model=main,components={role:role for role in entries})
|
||||
profiles=Mock();profiles.status.return_value=dict(profiles=[p]);profiles._component.side_effect=lambda model,role,id:entries[id]
|
||||
cli=root/'cli';cli.write_text('#!/usr/bin/env python3\nimport sys,wave\np=sys.argv[sys.argv.index("--out")+1]\nwith wave.open(p,"wb") as w:w.setparams((1,2,8000,0,"NONE","none"));w.writeframes(bytes(1600))\n');cli.chmod(0o755)
|
||||
runtime=Mock();runtime.status.return_value=dict(installed=True);runtime.paths.return_value=(cli,root)
|
||||
scheduler=Mock();scheduler.gpu_mode='music';release=Mock();scheduler.image_reservation.return_value=release
|
||||
return Music(root/'jobs',profiles,catalog,runtime,scheduler),release,p
|
||||
def wait(self,m):
|
||||
for _ in range(80):
|
||||
if m.status()['job']['state']!='running':return
|
||||
time.sleep(.05)
|
||||
self.fail('Test timeout')
|
||||
def test_success_and_output_without_retaining_prompts(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
m,release,p=self.make(root)
|
||||
with patch('music.probe',return_value=[dict(name='NVIDIA RTX 5080',uuid='GPU-test',free_mib=16000,processes=0)]):
|
||||
job=m.start('p','Synthetic lyrics','Synthetic pop',max_tokens=32,steps=1);self.wait(m)
|
||||
self.assertEqual(m.status()['job']['state'],'complete');self.assertTrue(m.audio(job['id']).startswith(b'RIFF'));release.assert_called_once()
|
||||
self.assertFalse((m.root/job['id']/'worker.tmp').exists());self.assertNotIn('lyrics',m.status()['job'])
|
||||
def test_occupied_foreign_gpu_releases_lease(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
m,release,p=self.make(root)
|
||||
with patch('music.probe',return_value=[dict(name='NVIDIA RTX 5080',uuid='GPU-test',free_mib=16000,processes=1)]):m.start('p','Synthetic','pop');self.wait(m)
|
||||
self.assertEqual(m.status()['job']['state'],'failed');self.assertIn('Fremde',m.status()['job']['phase']);release.assert_called_once()
|
||||
def test_invalid_request_and_missing_components(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
m,release,p=self.make(root)
|
||||
with self.assertRaises(ValueError):m.start('p','Synthetic','pop',max_tokens=True)
|
||||
m.profiles._component.side_effect=ValueError('missing');self.assertEqual(len(m.blockers(p)),5)
|
||||
with self.assertRaises(ValueError):m.audio('../outside')
|
||||
|
||||
def test_cancel_terminates_owned_process_and_releases_gpu(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
m,release,p=self.make(root);m.runtime.paths()[0].write_text('#!/usr/bin/env python3\nimport time;time.sleep(30)\n')
|
||||
with patch('music.probe',return_value=[dict(name='NVIDIA RTX 5080',uuid='GPU-test',free_mib=16000,processes=0)]):
|
||||
m.start('p','Synthetic','pop')
|
||||
for _ in range(50):
|
||||
if m.process:break
|
||||
time.sleep(.02)
|
||||
m.stop();self.wait(m)
|
||||
self.assertEqual(m.status()['job']['state'],'cancelled');release.assert_called_once();self.assertFalse(m.status()['loaded'])
|
||||
@@ -0,0 +1,47 @@
|
||||
import tempfile,unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from profiles import Profiles,YUE2_REPO,YUE2_COMPONENTS,music_recipe
|
||||
from catalog import file_role
|
||||
class MusicComponentsTests(unittest.TestCase):
|
||||
def test_download_recipe_assignment_and_remaining_worker_block(self):
|
||||
model=dict(id='main',kind='music',repo=YUE2_REPO,file='yue2-3b-bf16.gguf',revision='a'*40)
|
||||
entries=[dict(id=role,kind='music',repo=YUE2_REPO,file=info['files'][0],revision='a'*40,size=100) for role,info in YUE2_COMPONENTS.items()]
|
||||
lookup={e['id']:e for e in [model]+entries}
|
||||
c=Mock();c.entry.side_effect=lambda id:lookup[id] if id in lookup else (_ for _ in ()).throw(ValueError('missing'))
|
||||
c.status.return_value=dict(entries=entries)
|
||||
c.files.return_value=dict(repo=YUE2_REPO,revision='a'*40,files=[dict(name=e['file'],size=100) for e in entries],gated=False,license='apache-2.0')
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p=Profiles(Path(d)/'profiles.json',c);p.music_runtime_ready=lambda:True
|
||||
p.rows=[dict(id='profile',kind='music',revision=1,name='YuE2',model_id='main',parameters={})]
|
||||
r=p.components('main');self.assertEqual(len(r['requirements']),5);self.assertTrue(r['runtime_installed']);c.files.assert_called_once_with(YUE2_REPO,'a'*40)
|
||||
self.assertTrue(all(len(x['available'])==1 for x in r['requirements']))
|
||||
p.assign(dict(id='profile',revision=1,components={e['id']:e['id'] for e in entries}))
|
||||
result=p.status()['profiles'][0];self.assertFalse(result['runnable']);self.assertEqual(len(result['blockers']),1)
|
||||
entries[0]['revision']='b'*40
|
||||
with self.assertRaises(ValueError):p._component(model,'vae','vae')
|
||||
def test_components_cannot_be_main_models(self):
|
||||
for info in YUE2_COMPONENTS.values():
|
||||
for filename in info['files']:self.assertFalse(file_role(filename)['profile_eligible'])
|
||||
self.assertIsNone(music_recipe(dict(kind='music',repo='other/Yue2',file='yue2-3b-bf16.gguf')))
|
||||
def test_manual_setup_available_in_every_category_and_preserves_recipe(self):
|
||||
for kind in ('chat','image','audio','stt','music','voice','video'):
|
||||
model=dict(id='main',kind=kind,repo='unknown/model',file='weights.gguf',revision='a'*40)
|
||||
component=dict(id='aux',kind=kind,repo='external/repo',file='vae.safetensors',revision='b'*40,size=1)
|
||||
c=Mock();c.entry.side_effect=lambda id: {'main':model,'aux':component}[id]
|
||||
c.status.return_value=dict(entries=[component]);c.files.return_value=dict(repo='unknown/model',revision='a'*40,files=[],gated=False,url='https://huggingface.co/unknown/model',license=None)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p=Profiles(Path(d)/'profiles.json',c);p.rows=[dict(id='p',kind=kind,revision=1,name='Test',model_id='main',parameters={})]
|
||||
self.assertTrue(p.components('main')['manual'])
|
||||
p.assign(dict(id='p',revision=1,components={'manual_vae':'aux'}))
|
||||
self.assertEqual(p.rows[0]['components']['manual_vae'],'aux')
|
||||
self.assertTrue(p.references('aux'))
|
||||
with self.assertRaises(ValueError):p.assign(dict(id='p',revision=2,components={'arbitrary':'aux'}))
|
||||
def test_audio_runtime_package_definition_matches_exact_repo_and_model(self):
|
||||
import json
|
||||
from profiles import audio_package_recipe
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
Path(d,'model_specs').mkdir();Path(d,'model_specs/test.json').write_text(json.dumps(dict(packages=[dict(target_directory='Example-GGUF',files=['model.gguf','config.json'])])))
|
||||
model=dict(repo='audio-cpp/Example-GGUF',file='model.gguf')
|
||||
self.assertEqual(audio_package_recipe(model,d)['runtime_file_0']['files'],['config.json'])
|
||||
self.assertIsNone(audio_package_recipe(dict(model,repo='other/Example-GGUF'),d))
|
||||
@@ -0,0 +1,57 @@
|
||||
import io,json,tempfile,unittest,wave
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock,patch
|
||||
from separator_runtime import SeparatorRuntime,MODELS
|
||||
from separator import SeparatorTests
|
||||
|
||||
def audio(channels=2,duration=.1):
|
||||
data=io.BytesIO()
|
||||
with wave.open(data,'wb') as wav:
|
||||
wav.setnchannels(channels);wav.setsampwidth(2);wav.setframerate(44100);wav.writeframes(b'\x00'*(int(44100*duration)*channels*2))
|
||||
return data.getvalue()
|
||||
class Tests(unittest.TestCase):
|
||||
def test_marker_cannot_escape_and_failed_install_not_published(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
Path(root,'active.json').write_text(json.dumps({'id':'../../outside'}));runtime=SeparatorRuntime(root)
|
||||
self.assertFalse(runtime.status()['installed']);runtime.job={'id':'a'*32,'state':'running'}
|
||||
with patch.object(runtime,'_command',side_effect=RuntimeError('synthetic failure')):runtime._run()
|
||||
self.assertEqual(runtime.job['state'],'failed');self.assertFalse(runtime.status()['installed'])
|
||||
def test_model_requires_configuration_and_unknown_download_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
runtime=SeparatorRuntime(root);models=Path(root,'models');models.mkdir();name=next(iter(MODELS));(models/name).write_bytes(b'weights')
|
||||
self.assertFalse(runtime.model_ready(name));(models/MODELS[name][1]).write_text('model: {}');(models/(name+'.ready')).write_text('{}');self.assertFalse(runtime.model_ready(name))
|
||||
from separator_runtime import SOURCES
|
||||
(models/(name+'.ready')).write_text(json.dumps({f['file']:f['sha256'] for f in SOURCES[name]['files']}));self.assertTrue(runtime.model_ready(name))
|
||||
with self.assertRaises(ValueError):runtime.download('../other')
|
||||
with self.assertRaises(ValueError):runtime.download([])
|
||||
def test_stereo_accepted_and_long_or_truncated_wav_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
runtime=Mock();runtime.status.return_value={'installed':True};runtime.model_ready.return_value=True
|
||||
worker=SeparatorTests(root,runtime,Mock());model=next(iter(MODELS))
|
||||
with patch('separator.threading.Thread') as thread:
|
||||
self.assertEqual(worker.start(model,audio())['state'],'running');thread.return_value.start.assert_called_once()
|
||||
worker.job=None
|
||||
for data in (audio(duration=31),audio()[:-2],b'not a wav'):
|
||||
with self.assertRaises(ValueError):worker.start(model,data)
|
||||
def test_busy_gpu_fails_without_spawning_and_releases_lease(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
scheduler=Mock();release=Mock();scheduler.image_reservation.return_value=release
|
||||
worker=SeparatorTests(root,Mock(),scheduler);worker.job={'id':'a'*32,'state':'running','started_at':0}
|
||||
with patch('separator.probe',return_value=[{'name':'RTX 3060','processes':1,'free_mib':11000}]),patch('separator.subprocess.Popen') as process:worker._run(next(iter(MODELS)),audio())
|
||||
self.assertEqual(worker.job['state'],'failed');process.assert_not_called();release.assert_called_once()
|
||||
def test_success_publishes_only_finished_outputs_and_removes_input(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
scheduler=Mock();release=Mock();scheduler.image_reservation.return_value=release
|
||||
runtime=Mock();runtime.paths.return_value=(Path('/fake/python'),Path('/fake'));runtime.root=Path(root,'runtime')
|
||||
worker=SeparatorTests(root,runtime,scheduler);worker.job={'id':'a'*32,'state':'running','started_at':0}
|
||||
directory=Path(root,'a'*32)
|
||||
def launch(*args,**kwargs):
|
||||
(directory/'vocals.wav').write_bytes(audio());(directory/'results.json').write_text('["vocals.wav"]')
|
||||
process=Mock();process.poll.return_value=0;process.returncode=0;return process
|
||||
with patch('separator.probe',return_value=[{'name':'RTX 3060','uuid':'GPU-test','processes':0,'free_mib':11000}]),patch('separator.subprocess.Popen',side_effect=launch):worker._run(next(iter(MODELS)),audio())
|
||||
self.assertEqual(worker.job['state'],'complete');self.assertEqual(worker.audio('a'*32,0),audio());self.assertFalse((directory/'input.wav').exists());self.assertFalse((directory/'worker.tmp').exists());release.assert_called_once()
|
||||
def test_result_traversal_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
worker=SeparatorTests(root,Mock(),Mock());directory=Path(root,'a'*32);directory.mkdir();(directory/'complete').touch();(directory/'results.json').write_text('["../../secret.wav"]')
|
||||
with self.assertRaises(ValueError):worker.audio('a'*32,0)
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,19 @@
|
||||
import hashlib,json,tempfile,unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from separator_runtime import SOURCES,MODELS,verify_package,package_files
|
||||
class Tests(unittest.TestCase):
|
||||
def test_demucs_ensemble_complete_and_sources_https(self):
|
||||
self.assertEqual(set(SOURCES),set(MODELS))
|
||||
self.assertEqual(len(package_files('htdemucs_ft.yaml')),5)
|
||||
self.assertEqual(len(package_files('htdemucs_6s.yaml')),2)
|
||||
for recipe in SOURCES.values():
|
||||
for f in recipe['files']:
|
||||
self.assertEqual(len(f['sha256']),64)
|
||||
self.assertTrue(all(u.startswith('https://') for u in f['urls']))
|
||||
def test_wrong_or_missing_weight_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
recipe={'x':{'files':[{'file':'weight','sha256':hashlib.sha256(b'valid').hexdigest()}]}}
|
||||
with patch.dict(SOURCES,recipe):
|
||||
self.assertFalse(verify_package(d,'x'));Path(d,'weight').write_bytes(b'wrong');self.assertFalse(verify_package(d,'x'))
|
||||
Path(d,'weight').write_bytes(b'valid');self.assertTrue(verify_package(d,'x'))
|
||||
@@ -31,6 +31,44 @@ class Tests(unittest.TestCase):
|
||||
def request(self,path,method='GET',headers=None):
|
||||
req=urllib.request.Request(self.url+'/api/v1/'+path,method=method,headers={"Cookie":self.cookie,**(headers or {})})
|
||||
with urllib.request.urlopen(req) as r:return json.load(r)
|
||||
def test_catalog_checks_runtime_once_without_video_model_scan(self):
|
||||
entries=[dict(id=str(i),repo='test/model',file='model.gguf',kind='chat',size=8) for i in range(12)]
|
||||
with patch.object(self.server.catalog,'status',return_value=dict(entries=entries)), \
|
||||
patch.object(self.server.runtime,'status',return_value={'active':True}) as runtime, \
|
||||
patch.object(self.server.video,'status',side_effect=AssertionError('No video model scan needed')):
|
||||
result=self.request('catalog')
|
||||
self.assertEqual(len(result['entries']),12)
|
||||
self.assertEqual(runtime.call_count,1)
|
||||
self.assertTrue(all('execution' in row and 'used_by_profiles' in row for row in result['entries']))
|
||||
|
||||
def test_container_actions_require_admin_and_delete_confirmation(self):
|
||||
ident='a'*64
|
||||
with patch.object(self.server.docker,'container_action',return_value={'changed':True}) as action:
|
||||
for operation in ('start','stop','delete'):
|
||||
data={'id':ident,**({'confirm':True} if operation=='delete' else {})}
|
||||
req=urllib.request.Request(self.url+'/api/v1/docker/'+operation,data=json.dumps(data).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(req) as response:self.assertTrue(json.load(response)['changed'])
|
||||
action.assert_called_with(operation,ident)
|
||||
count=action.call_count
|
||||
req=urllib.request.Request(self.url+'/api/v1/docker/delete',data=json.dumps({'id':ident}).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
|
||||
self.assertEqual(error.exception.code,400);error.exception.close()
|
||||
req=urllib.request.Request(self.url+'/api/v1/docker/start',data=json.dumps({'id':ident}).encode(),headers={'X-Athena-Deck':'1','Content-Type':'application/json'})
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
|
||||
self.assertEqual(error.exception.code,401);error.exception.close()
|
||||
self.assertEqual(action.call_count,count)
|
||||
|
||||
def test_separator_runtime_is_admin_only_and_rejects_unknown_package(self):
|
||||
status=self.request('separator-runtime')
|
||||
self.assertFalse(status['installed']);self.assertEqual(len(status['models']),4)
|
||||
self.assertTrue(all(m['source']['files'] for m in status['models']))
|
||||
req=urllib.request.Request(self.url+'/api/v1/separator-runtime/download',data=b'{"model":"unknown"}',headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
|
||||
self.assertEqual(error.exception.code,400);error.exception.close()
|
||||
req=urllib.request.Request(self.url+'/api/v1/separator-runtime/install',data=b'{}',headers={'Authorization':'Bearer '+self.api_token,'X-Athena-Deck':'1','Content-Type':'application/json'})
|
||||
with self.assertRaises(urllib.error.HTTPError) as error:urllib.request.urlopen(req)
|
||||
self.assertEqual(error.exception.code,401);error.exception.close()
|
||||
|
||||
def test_demo_removed_from_status(self):
|
||||
state=self.request('status')
|
||||
self.assertNotIn('demo',state)
|
||||
|
||||
@@ -44,3 +44,15 @@ class VideoComfyTests(unittest.TestCase):
|
||||
self.assertTrue(self.video.service_authenticated('Bearer synthetic-service-secret'))
|
||||
self.assertFalse(self.video.service_authenticated('Bearer wrong'))
|
||||
self.assertFalse(self.video.service_authenticated('Bearer ü'))
|
||||
|
||||
def test_music_switch_is_exclusive_and_returns_to_llm(self):
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
scheduler=SimpleNamespace(gpu_mode='llm',cv=threading.Condition(),active=0,tts_active=0,transition=False,key=('chat',))
|
||||
self.video.scheduler=scheduler;self.video._stop=Mock();self.video._foreign_services=lambda:[]
|
||||
self.video.music_profiles=lambda:[dict(id='music',name='YuE2')]
|
||||
self.video.switch('music');self.video.thread.join(2)
|
||||
self.assertEqual(scheduler.gpu_mode,'music');self.assertEqual(self.video.state,'ready');self.video.stop_owned.assert_called_once();self.video._stop.assert_called_once()
|
||||
self.video.switch('llm');self.video.thread.join(2);self.assertEqual(scheduler.gpu_mode,'llm')
|
||||
def test_music_requires_enabled_ready_profile(self):
|
||||
with self.assertRaisesRegex(ValueError,'Musikprofil'):self.video.switch('music')
|
||||
|
||||
+15
-1
@@ -17,8 +17,10 @@ class CapabilitiesTests(unittest.TestCase):
|
||||
for value in (-1,32769,True,1.5):
|
||||
with self.assertRaises(ValueError):chat_parameters({'gpu_devices':['GPU-a'],'gpu_reserve_mode':'manual','gpu_reserve_mib':{'GPU-a':value}})
|
||||
with self.assertRaises(ValueError):chat_parameters({'gpu_reserve_mode':'manual'})
|
||||
def test_projector_device_requires_selected_gpu(self):
|
||||
def test_projector_device_validates_uuid_independently(self):
|
||||
with self.assertRaises(ValueError):chat_parameters({'vision_device':'GPU-missing'})
|
||||
uuid='GPU-4834d9d7-5b61-3004-1fb3-4ae49d482d4b'
|
||||
self.assertEqual(chat_parameters({'vision_device':uuid})['vision_device'],uuid)
|
||||
|
||||
class ProjectorLaunchTests(WorkerTests):
|
||||
def test_cpu_and_gpu_projector_flags_and_reserve(self):
|
||||
@@ -32,4 +34,16 @@ class ProjectorLaunchTests(WorkerTests):
|
||||
if device=='cpu':self.assertIn('--no-mmproj-offload',args)
|
||||
else:self.assertEqual(args[args.index('--mmproj-device')+1],'CUDA1');self.assertEqual(self.worker.memory_plan['gpus'][1]['required_mib'],7072)
|
||||
self.worker.process=None
|
||||
def test_projector_only_gpu_excluded_from_model_devices(self):
|
||||
self.profile['parameters'].update(gpu_devices=['gpu-first'],split_mode='none',tensor_split=[],vision_projector='p',vision_device='gpu-second',gpu_offload='full')
|
||||
self.worker.catalog.entry=lambda ident:dict(id='p',file='mmproj.gguf',size=1024**3,role='vision_projector') if ident=='p' else self.entry
|
||||
process=Mock();process.poll.return_value=None;http=Mock();http.getresponse.return_value.status=200
|
||||
with self.patches(),patch('inference.probe',return_value=self.gpus),patch('inference.cgroup_headroom',return_value=32*1024**3),patch.object(self.worker,'_fit_command',return_value='CUDA0 9000 1000 1000\nHost 512 0 256\n'),patch('inference.subprocess.Popen',return_value=process) as launch,patch('inference.http.client.HTTPConnection',return_value=http),patch('inference.threading.Thread'):
|
||||
self.worker.ensure(self.profile)
|
||||
args=launch.call_args.args[0]
|
||||
self.assertEqual(args[args.index('--device')+1],'CUDA0')
|
||||
self.assertEqual(args[args.index('--mmproj-device')+1],'CUDA1')
|
||||
self.assertEqual(self.worker.memory_plan['gpus'][1]['required_mib'],3072)
|
||||
self.assertEqual(launch.call_args.kwargs['env']['CUDA_VISIBLE_DEVICES'],'gpu-first,gpu-second')
|
||||
self.worker.process=None
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ window.VideoUI=(()=>{
|
||||
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 s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
|
||||
function mode(root,s,profiles=[],port=null){
|
||||
if(root.dataset.busy==='1')return;
|
||||
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: ${s.runtime_kind==='original'?'originale LTX-API':'ComfyUI-API'}</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.service?.api_url?`<p>Backend: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>${s.service.ready&&port?`<a class="link" target="_blank" rel="noopener" href="${e(location.protocol+"//"+location.hostname+":"+(s.runtime_kind==='original'?8118:port)+"/")}">${s.runtime_kind==='original'?'LTX DeskWEB öffnen':'ComfyUI öffnen'} →</a>`:""}`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a><div class="video-message" role="status"></div></section>`;
|
||||
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: ${s.runtime_kind==='original'?'originale LTX-API':'ComfyUI-API'}</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><button data-mode="music" ${!s.music_profiles?.length||s.mode==='music'||s.state==='switching'?'disabled':''}>Musik</button><h3>${e(s.mode==='music'?s.music_profiles?.map(p=>p.name).join(', '):s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='music'?'Musik bereit · Gewichte laden bei Anfrage':s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.mode!=='music'&&s.service?.api_url?`<p>Backend: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>${s.service.ready&&port?`<a class="link" target="_blank" rel="noopener" href="${e(location.protocol+"//"+location.hostname+":"+(s.runtime_kind==='original'?8118:port)+"/")}">${s.runtime_kind==='original'?'LTX DeskWEB öffnen':'ComfyUI öffnen'} →</a>`:""}`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a>${!s.music_profiles?.length?'<p>Für Musik zuerst unter <a href="#music">Musik → Profile</a> ein ausführbares Profil am API-Endpunkt freigeben.</p>':''}<div class="video-message" role="status"></div></section>`;
|
||||
root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next,profiles,port);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}});
|
||||
}
|
||||
function services(root){
|
||||
@@ -12,12 +12,12 @@ window.VideoUI=(()=>{
|
||||
root.innerHTML=`<section class="card"><h2>Video-Laufzeit</h2><label>Laufzeit auswählen<select id="video-runtime-choice" ${s.mode!=='llm'?'disabled':''}><option value="comfy" ${s.runtime_kind!=='original'?'selected':''}>ComfyUI · gemeinsam mit Bildgenerierung</option><option value="original" ${s.runtime_kind==='original'?'selected':''}>LTX Original · LTX DeskWEB</option></select></label><p>${s.runtime?.installed?'Vorhanden · bereit':s.runtime_kind==='original'?'Nicht installiert · unter Einstellungen → LTX Original einrichten':'Nicht installiert · unter Einstellungen → Bildgenerierung einrichten'}</p><p>5080: Diffusionsmodell und VAE. 3060: Textencoder. Große BF16-Dateien nutzen zusätzlich CPU-Auslagerung. Gewichte laden erst bei einer Anfrage.</p><a href="#ltx-original-runtime">LTX Original installieren / Status →</a></section>${(s.models||[]).map(x=>`<section class="card"><h3>${e(x.model.file)}</h3><strong>${e(x.label)}</strong><button class="secondary" data-components-model="${e(x.model.id)}">Komponenten prüfen / nachladen</button>${x.blockers.map(b=>`<p class="warning">${e(b)}</p>`).join('')}<p>${Object.keys(x.components).length} Komponenten geprüft · gleiche Quellversion</p><button data-select="${e(x.model.id)}" ${s.mode!=='llm'||x.model.id===s.selected?'disabled':''}>${x.model.id===s.selected?'Aktives Videomodell':'Als Videomodell aktivieren'}</button>${x.model.id===s.selected&&s.mode==='llm'?'<button data-select="">Auswahl aufheben</button>':''}</section>`).join('')||'<p>Kein unterstütztes LTX-Modell in der Bibliothek. Unter Entdecken herunterladen.</p>'}<p class="video-message" role="status"></p>`;
|
||||
root.querySelector('#video-runtime-choice').onchange=async event=>{root.dataset.busy='1';try{await api('video/runtime',{runtime:event.target.value});}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';poll();}};
|
||||
root.querySelectorAll('[data-components-model]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';try{const data=await api('profiles/components?'+new URLSearchParams({model_id:b.dataset.componentsModel}));root.innerHTML=`<section class="card"><h2>LTX-Komponenten</h2><p>${e(data.message)}</p>${data.requirements.map((r,i)=>`<p><strong>${e(r.label)}</strong> · ${r.available.length?'Bereits heruntergeladen':r.candidates.length?'Fehlt':'In dieser Quellversion nicht gefunden'}</p>${!r.available.length?r.candidates.map((c,j)=>`<button data-download-component="${i}:${j}">${e(c.name)} herunterladen</button>`).join(''):''}`).join('')}<p class="video-message"></p><button data-close-components>Zurück</button></section>`;root.querySelector('[data-close-components]').onclick=()=>{root.dataset.busy='0';poll();};root.querySelectorAll('[data-download-component]').forEach(d=>d.onclick=async()=>{const [i,j]=d.dataset.downloadComponent.split(':');const f=data.requirements[i].candidates[j];d.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:'video'});root.querySelector('.video-message').textContent='Download eingeplant. Fortschritt unter Downloads. Nach Abschluss werden die Komponenten automatisch erkannt.';}catch(x){root.querySelector('.video-message').textContent=x.message;d.disabled=false;}});}catch(x){root.dataset.busy='0';root.querySelector('.video-message').textContent=x.message;}});
|
||||
root.querySelectorAll('[data-select]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.dataset.select});root.querySelector('.video-message').textContent='Videomodell ausgewählt. Start über die Übersicht.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});
|
||||
root.querySelectorAll('[data-select]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';try{await api('video/service',{id:b.dataset.select});root.querySelector('.video-message').textContent='Videomodell ausgewählt. Start über die Steuerung.';}catch(x){root.querySelector('.video-message').textContent=x.message;}finally{root.dataset.busy='0';}});
|
||||
}}catch(x){root.textContent=x.message;}if(root.isConnected)setTimeout(poll,5000);}poll();
|
||||
}
|
||||
function runtime(){
|
||||
const view=document.querySelector('#view');if(view.querySelector('#video-service-page'))return;
|
||||
view.innerHTML='<section id="video-service-page"><h1>Video</h1><p>LTX Original mit LTX DeskWEB oder die vorhandene ComfyUI-Laufzeit auswählen. Deck verwaltet Modelle, Komponenten und den GPU-Modus; Generierungsparameter gehören in die jeweilige Video-Oberfläche.</p><div class="studio-tabs"><button data-video-tab="runtime">Laufzeit / Aktives Modell</button><button data-video-tab="discover">Entdecken</button><button data-video-tab="library">Bibliothek</button><button data-video-tab="downloads">Downloads</button></div><div id="video-content"></div></section>';
|
||||
view.innerHTML='<section id="video-service-page"><h1>Videogenerierung</h1><p>LTX Original mit LTX DeskWEB oder die vorhandene ComfyUI-Laufzeit auswählen. Deck verwaltet Modelle, Komponenten und den GPU-Modus; Generierungsparameter gehören in die jeweilige Video-Oberfläche.</p><div class="studio-tabs"><button data-video-tab="runtime">Laufzeit / Aktives Modell</button><button data-video-tab="discover">Entdecken</button><button data-video-tab="library">Bibliothek</button><button data-video-tab="downloads">Downloads</button></div><div id="video-content"></div></section>';
|
||||
function show(tab){const root=view.querySelector('#video-content');if(tab==='runtime'){root.innerHTML='<div id="video-services"></div>';services(root.firstChild);}else{root.innerHTML=CatalogUI.html(tab==='library',tab==='downloads');CatalogUI.bind('video',tab==='library',tab==='downloads');}}
|
||||
view.querySelectorAll('[data-video-tab]').forEach(b=>b.onclick=()=>show(b.dataset.videoTab));show('runtime');
|
||||
}
|
||||
|
||||
+12
-5
@@ -55,11 +55,12 @@ class VideoComfy:
|
||||
self.state='failed';self.error='ComfyUI wurde unerwartet beendet. Auf LLM wechseln und Laufzeit erneut starten.'
|
||||
rows=self.models();ident=self.selection.get('model_id');row=next((x for x in rows if x['model']['id']==ident),None)
|
||||
service=dict(id=ident,name=row['model']['file'] if row else 'Kein Videomodell ausgewählt',running=running,ready=running and self.state=='ready',api_url='/ (ComfyUI auf dem gemeinsamen API-Port)',message='5080: Diffusion/VAE · 3060: Textencoder · große BF16-Gewichte nutzen CPU-Auslagerung.') if row else {}
|
||||
return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status())
|
||||
return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,music_profiles=getattr(self,'music_profiles',lambda:[])(),services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status())
|
||||
def switch(self,mode):
|
||||
if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.')
|
||||
if mode not in ('llm','video','music'):raise ValueError('Modus muss llm, video oder music sein.')
|
||||
with self.lock:
|
||||
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
|
||||
if mode=='music' and not getattr(self,'music_profiles',lambda:[])():raise ValueError('Zuerst ein ausführbares Musikprofil am API-Endpunkt freigeben.')
|
||||
if mode=='video':
|
||||
row=next((x for x in self.models() if x['model']['id']==self.selection.get('model_id')),None)
|
||||
if not row:raise ValueError('Zuerst Videomodell aktivieren.')
|
||||
@@ -70,8 +71,13 @@ class VideoComfy:
|
||||
return self.status()
|
||||
def _switch(self,mode):
|
||||
try:
|
||||
if mode=='llm':
|
||||
self._stop()
|
||||
self.stop_owned();self._stop();deadline=time.monotonic()+60
|
||||
with self.scheduler.cv:
|
||||
while self.scheduler.active or self.scheduler.tts_active or self.scheduler.transition:
|
||||
if time.monotonic()>deadline:raise ValueError('Deck-Aufträge noch aktiv.')
|
||||
self.scheduler.cv.wait(.2)
|
||||
self.scheduler.key=None
|
||||
if mode in ('llm','music'):
|
||||
if any(x.get('running') is not False for x in self._foreign_services()):raise ValueError('Fremder Videodienst blockiert LLM-Modus.')
|
||||
self.foreign_blocked=False
|
||||
else:
|
||||
@@ -86,7 +92,8 @@ class VideoComfy:
|
||||
self.scheduler.key=None
|
||||
self.switch_phase='Freie GPUs prüfen';self._start()
|
||||
with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all()
|
||||
self.state='ready' if mode=='video' else 'idle'
|
||||
self.state='ready' if mode in ('video','music') else 'idle'
|
||||
if mode=='music':self.switch_phase='Musik bereit · Gewichte laden bei Anfrage'
|
||||
except Exception as exc:
|
||||
self._stop();self.error=str(exc);self.state='failed'
|
||||
with self.scheduler.cv:self.scheduler.gpu_mode='video' if self.foreign_blocked else 'llm';self.scheduler.cv.notify_all()
|
||||
|
||||
Reference in New Issue
Block a user