diff --git a/README.md b/README.md index 9b777f1..15489ca 100644 --- a/README.md +++ b/README.md @@ -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). 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). diff --git a/deploy/docker_backup.py b/deploy/docker_backup.py index 658bbfe..645d580 100644 --- a/deploy/docker_backup.py +++ b/deploy/docker_backup.py @@ -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 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'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.') + 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','') 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']=='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.') 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']=='athena-ladypoly': + 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.') 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(): diff --git a/deploy/install.py b/deploy/install.py index 65ec1d9..ae3c521 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['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): diff --git a/deploy/ladypoly/Dockerfile b/deploy/ladypoly/Dockerfile new file mode 100644 index 0000000..39f6e4a --- /dev/null +++ b/deploy/ladypoly/Dockerfile @@ -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('', '
LadyPoly mit Athena Deck · Deck-Verbindung einrichten · In Deck Musik aktivieren. Worker-Abbruch in Deck.
'); s=s.replace('value=\"full\" checked', 'value=\"full\" disabled').replace('value=\"melody\"', 'value=\"melody\" disabled').replace('value=\"off\"', 'value=\"off\" checked'); s=s.replace('', ''); 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"] diff --git a/deploy/ladypoly/README.md b/deploy/ladypoly/README.md new file mode 100644 index 0000000..13b3055 --- /dev/null +++ b/deploy/ladypoly/README.md @@ -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. diff --git a/deploy/ladypoly/bridge.py b/deploy/ladypoly/bridge.py new file mode 100644 index 0000000..e134d07 --- /dev/null +++ b/deploy/ladypoly/bridge.py @@ -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='''

LadyPoly · Deck-Verbindung

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.

LadyPoly öffnen →

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.

''' + +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 diff --git a/deploy/ladypoly/install.py b/deploy/ladypoly/install.py new file mode 100644 index 0000000..cb31139 --- /dev/null +++ b/deploy/ladypoly/install.py @@ -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') diff --git a/deploy/ladypoly/test_bridge.py b/deploy/ladypoly/test_bridge.py new file mode 100644 index 0000000..e129fa8 --- /dev/null +++ b/deploy/ladypoly/test_bridge.py @@ -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() diff --git a/deploy/setup_docker_helper.py b/deploy/setup_docker_helper.py index a8d85e9..53674d2 100644 --- a/deploy/setup_docker_helper.py +++ b/deploy/setup_docker_helper.py @@ -25,6 +25,9 @@ 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) if args.deck_state: registered=Path(args.deck_state).resolve() if not registered.is_dir():raise SystemExit('Deck-Zustandsverzeichnis fehlt.') diff --git a/docker-ui.js b/docker-ui.js index d22342c..0c13499 100644 --- a/docker-ui.js +++ b/docker-ui.js @@ -4,7 +4,7 @@ window.DockerUI=(()=>{ function html(services=false){return `
${services?'ANWENDUNGEN':'EINSTELLUNGEN / LAUFZEITEN'}

${services?'Weitere Dienste':'Docker'}

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

`;} 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=`

Explizite Zuordnung

Beide Container-Labels müssen gesetzt sein:

io.athena-deck.managed=true\nio.athena-deck.role=application

Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.

Docker-Verbindung prüfen →
${!s.helper_available||!s.reachable?'

Docker nicht verbunden

'+esc(s.message)+'

':s.services.length?'
'+s.services.map(c=>`
DOCKER · DECK-ANWENDUNG

${esc(c.name)}

${esc(c.image)}

${esc(c.state)} · ${esc(c.status)}

${c.name==='athena-swarm-ui'?`

API By URL · Deck steuert die Video-Laufzeit

SwarmUI öffnen →

In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.

`:''}
`).join('')+'
':'

Noch keine Deck-Anwendungen

Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.

'}`;} + if(services){panel.innerHTML=`

Explizite Zuordnung

Beide Container-Labels müssen gesetzt sein:

io.athena-deck.managed=true\nio.athena-deck.role=application

Keine automatische Übernahme vorhandener Container. Labels werden beim Erstellen in Docker Compose gesetzt. Diese Ansicht verändert keine Container oder Modelldateien.

Docker-Verbindung prüfen →
${!s.helper_available||!s.reachable?'

Docker nicht verbunden

'+esc(s.message)+'

':s.services.length?'
'+s.services.map(c=>`
DOCKER · DECK-ANWENDUNG

${esc(c.name)}

${esc(c.image)}

${esc(c.state)} · ${esc(c.status)}

${c.name==='athena-ladypoly'?`

LadyPoly YuE2 · Oberfläche für Decks Musiklaufzeit

LadyPoly öffnen →

Zuerst Deck-Verbindung einrichten und in der Übersicht Musik aktivieren. SSH-Tunnel: Port 8127.

`:''}${c.name==='athena-swarm-ui'?`

API By URL · Deck steuert die Video-Laufzeit

SwarmUI öffnen →

In Deck zuerst Video aktivieren. Zugriff über den SSH-Tunnel auf Port 8125.

`:''}
`).join('')+'
':'

Noch keine Deck-Anwendungen

Docker ist erreichbar. Kein Container trägt beide erforderlichen Labels. Bestehende Router-, WireGuard- und andere Container bleiben ausgeblendet.

'}`;} else {panel.innerHTML=`
${s.installed?'INSTALLIERT':s.installed===false?'NICHT INSTALLIERT':'NICHT ERMITTELBAR'}

Docker Engine ${esc(s.version||'')}

${s.reachable?'Daemon erreichbar · Verbindung über begrenzten Systemhelfer':s.helper_available?'Systemhelfer erreichbar · Docker-Daemon nicht erreichbar':'Systemhelfer nicht verbunden'}

${esc(s.message)}

${s.job?`

${esc(s.job.state)} · ${esc(s.job.phase)}

`:''}${!s.installed&&s.install_supported&&!running?'

':``}

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.

Weitere Dienste öffnen →
`; 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='';}};} } diff --git a/endpoint.py b/endpoint.py index ea62935..f5c1582 100644 --- a/endpoint.py +++ b/endpoint.py @@ -197,7 +197,7 @@ 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') diff --git a/test_backup.py b/test_backup.py index 6003352..e46c50e 100644 --- a/test_backup.py +++ b/test_backup.py @@ -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')): 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})) diff --git a/test_endpoint.py b/test_endpoint.py index ccc0f97..550bfa9 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -69,6 +69,8 @@ class EndpointTests(unittest.TestCase): 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})