Add managed LadyPoly WebUI using Deck music API
This commit is contained in:
+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 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():
|
||||
|
||||
+1
-1
@@ -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):
|
||||
|
||||
@@ -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,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')
|
||||
@@ -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()
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user