Add managed LadyPoly WebUI using Deck music API

This commit is contained in:
Mikei386 committed 2026-09-30 22:32:17 +02:00
1 parent 3aa2301a53
commit cf3e354434
13 files changed
+218 -10

No files matched your search

+6
View File
@@ -200,3 +200,9 @@ 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). 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). 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).
+15 -7
View File
@@ -20,6 +20,8 @@ def config_files(source,destination):
p=Path(source) p=Path(source)
if destination.startswith('/run/secrets/'): if destination.startswith('/run/secrets/'):
return {'file':blob(p)} if p.exists() else {} 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(): if destination.endswith('/Data') and p.is_dir():
files={} files={}
for name in ('Settings.fds','Backends.fds'): for name in ('Settings.fds','Backends.fds'):
@@ -43,7 +45,7 @@ def export(manager):
for row in manager.inventory(): 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] 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) 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=[] mounts=[]
for i,m in enumerate(x['Mounts']): for i,m in enumerate(x['Mounts']):
source=m['Source'];relative=None source=m['Source'];relative=None
@@ -51,7 +53,7 @@ def export(manager):
try:relative=str(Path(source).relative_to(deck)) try:relative=str(Path(source).relative_to(deck))
except ValueError:pass 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())) 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 None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd'))))
return {'services':result,'configured':bool(deck)} return {'services':result,'configured':bool(deck)}
def validate(c): 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 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('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') 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('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb','athena-ladypoly'):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.') recipes={'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','') 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 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.') 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'] 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.') 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(): 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']=='athena-ladypoly' 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.') if not isinstance(data,str) or len(base64.b64decode(data,validate=True))>MAX_FILE:raise ValueError('Ungültige Konfigurationsdatei.')
return c return c
@@ -110,8 +115,10 @@ def restore(manager,c):
# Never replace or restart an existing service during a restore. # 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.') 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.'} return {'state':'reused','message':'Vorhandener markierter Container unverändert übernommen.'}
if c.get('build_recipe')=='swarm-ui': if c['name']=='athena-ladypoly':
source=Path(__file__).parent/'swarm-ui' with socket.socket() as probe:probe.bind(('127.0.0.1',8127))
if c.get('build_recipe') in ('swarm-ui','ladypoly'):
source=Path(__file__).parent/c['build_recipe']
if not (source/'Dockerfile').is_file():raise ValueError('Gepinntes Swarm-Build-Rezept fehlt im Systemhelfer.') if not (source/'Dockerfile').is_file():raise ValueError('Gepinntes Swarm-Build-Rezept fehlt im Systemhelfer.')
command(['build','-t',c['image'],str(source)],1800) command(['build','-t',c['image'],str(source)],1800)
else: else:
@@ -135,6 +142,7 @@ def restore(manager,c):
if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.') if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.')
base.mkdir(parents=True,exist_ok=True,mode=0o700) 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'] 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['memory']:args+=['--memory',str(c['memory'])]
if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)] if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)]
for k,v in c['labels'].items(): for k,v in c['labels'].items():
+1 -1
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['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'] FILES = ['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): def run(*args, check=True, interactive=False):
+14
View File
@@ -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"]
+52
View File
@@ -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.
+73
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
"""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.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')
+30
View File
@@ -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()
+3
View File
@@ -25,6 +25,9 @@ def main():
(base/'swarm-ui').mkdir(exist_ok=True) (base/'swarm-ui').mkdir(exist_ok=True)
for name in ('Dockerfile','patch-source.py','proxy.py','entrypoint.py'): for name in ('Dockerfile','patch-source.py','proxy.py','entrypoint.py'):
shutil.copyfile(Path(__file__).parent/'swarm-ui'/name,base/'swarm-ui'/name) 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)
if args.deck_state: if args.deck_state:
registered=Path(args.deck_state).resolve() registered=Path(args.deck_state).resolve()
if not registered.is_dir():raise SystemExit('Deck-Zustandsverzeichnis fehlt.') if not registered.is_dir():raise SystemExit('Deck-Zustandsverzeichnis fehlt.')
+1 -1
View File
@@ -4,7 +4,7 @@ window.DockerUI=(()=>{
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 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; 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'; 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>'}`;} 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-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>`:''}</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>`; 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='';}};} 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='';}};}
} }
+1 -1
View File
@@ -197,7 +197,7 @@ class APIHandler(BaseHTTPRequestHandler):
with ep.lock: with ep.lock:
if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping') if not ep.allowed():raise APIError('Endpunkt wird gestoppt.',503,'endpoint_stopping')
ep.inflight+=1;admitted=True 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') if ep.scheduler.gpu_mode=='switching':raise APIError('Moduswechsel läuft.',503,'mode_switching')
video=ep.video.status() video=ep.video.status()
if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready') if not video.get('service',{}).get('ready'):raise APIError('Video-API noch nicht bereit.',503,'video_not_ready')
+5
View File
@@ -87,6 +87,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')): 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) bad=copy.deepcopy(c);change(bad)
with self.assertRaises(ValueError):validate_container(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): def test_never_replaces_existing_foreign_container(self):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
manager=Mock(state=Path(d));(Path(d)/'backup-policy.json').write_text(json.dumps({'deck_state':d})) manager=Mock(state=Path(d));(Path(d)/'backup-policy.json').write_text(json.dumps({'deck_state':d}))
+2
View File
@@ -69,6 +69,8 @@ class EndpointTests(unittest.TestCase):
self.assertNotIn('Music',[x['id'] for x in self.request()[1]['data']]) 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.assertEqual(self.request('/v1/audio/music',dict(model='Music',lyrics='Synthetic',style='pop'))[0],503)
self.ep.scheduler.gpu_mode='music' 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) 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=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}) conn.request('POST','/v1/audio/music',json.dumps(dict(model='Music',lyrics='Synthetic',style='pop')),{'Content-Type':'application/json','Authorization':'Bearer '+'A'*32})