Add exclusive music GPU mode beside LLM and video

This commit is contained in:
Mikei386 committed 2026-09-30 22:13:31 +02:00
1 parent 0ca40bc624
commit 3aa2301a53
12 files changed
+46 -16

No files matched your search

+1 -1
View File
@@ -204,7 +204,7 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals
backup_name=config['name']+'-previous' backup_name=config['name']+'-previous'
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
stamp=str(time.time_ns()) stamp=str(time.time_ns())
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('music-jobs','audio-cpp-runtime','ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*')) shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('music-verification','music-test-temp','music-jobs','audio-cpp-runtime','ltx-original-runtime','original-work','tts-runtime','tts-tests','models','runtime','runtime-verification','image-tests','image-verification','image-verification-*','image-runtime','video-runtime','video-verification','video-verification-*','comfy-work','router-verification-*'))
was_running=previous['State']['Running'] was_running=previous['State']['Running']
if was_running:run('docker','stop','--time','15',config['name']) if was_running:run('docker','stop','--time','15',config['name'])
run('docker','rename',config['name'],backup_name) run('docker','rename',config['name'],backup_name)
+4
View File
@@ -23,3 +23,7 @@ Die Ladypoly-WebUI ist hiermit noch nicht verbunden. Der Deck-Testbereich und di
## Verifikation auf Athena ## Verifikation auf Athena
2026-09-30: YuE2 BF16 mit zugeordnetem F16-VAE und Sidecars erzeugte bei 200 semantischen Tokens / 2 NAR-Schritten eine gültige stereo WAV von 8 Sekunden in etwa 18 Sekunden auf der RTX 5080. Danach Prozess beendet und GPU-Speicher freigegeben. Dies prüft Ausführung und Dateiformat, nicht musikalische Qualität oder Stabilität langer Aufträge. 2026-09-30: YuE2 BF16 mit zugeordnetem F16-VAE und Sidecars erzeugte bei 200 semantischen Tokens / 2 NAR-Schritten eine gültige stereo WAV von 8 Sekunden in etwa 18 Sekunden auf der RTX 5080. Danach Prozess beendet und GPU-Speicher freigegeben. Dies prüft Ausführung und Dateiformat, nicht musikalische Qualität oder Stabilität langer Aufträge.
## Exklusiver Musik-Modus
Übersicht → GPU-Modus bietet LLM, Video und Musik. Musik setzt mindestens ein am Endpunkt freigegebenes ausführbares Musikprofil voraus. Beim Wechsel werden eigene Deck-GPU-Worker beendet und laufende GPU-Reservierungen freigegeben; fremde Videodienste werden geprüft, aber nie gestoppt. Musikgewichte laden weiterhin erst bei Anfrage. Im Musikmodus werden Chat/Bild/TTS/STT blockiert; für diese Aufträge auf LLM zurückschalten. Musik → Testen zeigt einen Umschalthinweis, solange ein anderer Modus aktiv ist.
+3 -1
View File
@@ -209,7 +209,8 @@ class APIHandler(BaseHTTPRequestHandler):
if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'}) if self.command=='GET' and self.path=='/health':return self.send({'status':'ok','service':'athena-deck-api'})
if self.video_route(ep):return if self.video_route(ep):return
if self.command!='POST':raise APIError('Route nicht gefunden.',404) if self.command!='POST':raise APIError('Route nicht gefunden.',404)
if ep.video and ep.scheduler.gpu_mode!='llm' and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active') if ep.scheduler.gpu_mode=='music' and self.path!='/v1/audio/music':raise APIError('Musik-Modus aktiv. Für andere KI-Aufträge auf LLM wechseln.',503,'music_mode_active')
if ep.video and ep.scheduler.gpu_mode not in ('llm','music') and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active')
if self.path=='/v1/audio/transcriptions':return self.transcription(ep) if self.path=='/v1/audio/transcriptions':return self.transcription(ep)
if self.path=='/v1/images/edits': if self.path=='/v1/images/edits':
from image_upload import read_image_upload from image_upload import read_image_upload
@@ -275,6 +276,7 @@ class APIHandler(BaseHTTPRequestHandler):
self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);return self.sent=True;self.send_response(200);self.send_header('Content-Type','audio/wav');self.send_header('Content-Length',str(len(body)));self.send_header('Cache-Control','no-store');self.send_header('Connection','close');self.end_headers();self.wfile.write(body);return
raise APIError('TTS-Zeitlimit überschritten.',504) raise APIError('TTS-Zeitlimit überschritten.',504)
def music_generation(self,ep,data): def music_generation(self,ep,data):
if ep.scheduler.gpu_mode!='music':raise APIError('Bitte in der Übersicht auf Musik umschalten.',503,'music_mode_required')
if not ep.music:raise APIError('Musikworker nicht eingerichtet.',501) if not ep.music:raise APIError('Musikworker nicht eingerichtet.',501)
if set(data)-{'model','lyrics','style','seed','max_tokens','steps'}:raise APIError('Nicht unterstützte Musikfelder.') if set(data)-{'model','lyrics','style','seed','max_tokens','steps'}:raise APIError('Nicht unterstützte Musikfelder.')
profile=ep.find_profile(data.get('model'),'music') profile=ep.find_profile(data.get('model'),'music')
+3 -3
View File
@@ -23,14 +23,14 @@ class Scheduler:
def status(self): def status(self):
with self.cv:return dict(active_requests=self.active+self.tts_active,waiting_requests=len(self.queue),switching=self.transition) with self.cv:return dict(active_requests=self.active+self.tts_active,waiting_requests=len(self.queue),switching=self.transition)
@contextlib.contextmanager @contextlib.contextmanager
def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True): def lease(self, key, slots=1, prepare=None, timeout=600, allowed=lambda:True, modes=("llm",)):
ticket=object();deadline=time.monotonic()+timeout;claimed=False ticket=object();deadline=time.monotonic()+timeout;claimed=False
with self.cv: with self.cv:
if len(self.queue)>=16:raise InferenceError('Warteschlange voll. Bitte später erneut versuchen.') if len(self.queue)>=16:raise InferenceError('Warteschlange voll. Bitte später erneut versuchen.')
self.queue.append(ticket) self.queue.append(ticket)
try: try:
while True: while True:
if self.gpu_mode!="llm":raise InferenceError("Video-Modus aktiv oder Moduswechsel läuft; GPU-Aufträge sind gesperrt.") if self.gpu_mode not in modes:raise InferenceError("Video-Modus aktiv oder Moduswechsel läuft; GPU-Aufträge sind gesperrt.")
if not allowed():raise InferenceError('Endpunkt wird gestoppt oder Profil ist nicht mehr aktiviert.') if not allowed():raise InferenceError('Endpunkt wird gestoppt oder Profil ist nicht mehr aktiviert.')
if self.queue[0] is ticket and not self.transition and (not self.active or (self.key==key and self.active<slots)) and (not self.tts_active or self.key==key and key!=('image',)): if self.queue[0] is ticket and not self.transition and (not self.active or (self.key==key and self.active<slots)) and (not self.tts_active or self.key==key and key!=('image',)):
self.queue.pop(0);self.active+=1;claimed=True self.queue.pop(0);self.active+=1;claimed=True
@@ -60,7 +60,7 @@ class Scheduler:
if claimed: if claimed:
with self.cv:self.active-=1;self.cv.notify_all() with self.cv:self.active-=1;self.cv.notify_all()
def image_reservation(self, wait=False,kind='image'): def image_reservation(self, wait=False,kind='image'):
lease=self.lease((kind,),timeout=600 if wait else 0) lease=self.lease((kind,),timeout=600 if wait else 0,modes=("music",) if kind=="music" else ("llm",))
lease.__enter__() lease.__enter__()
return lambda:lease.__exit__(None,None,None) return lambda:lease.__exit__(None,None,None)
def tts_reservation(self,wait=False): def tts_reservation(self,wait=False):
+1 -1
View File
@@ -3,7 +3,7 @@ window.MusicUI=(()=>{
async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const x=await r.json();if(!r.ok)throw Error(x.error||'Musikanfrage fehlgeschlagen');return x;} async function api(path,data){const r=await fetch('/api/v1/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)});const x=await r.json();if(!r.ok)throw Error(x.error||'Musikanfrage fehlgeschlagen');return x;}
function html(){return '<section id="music-test" class="card"><h2>Musik ausprobieren</h2><p>YuE2 über audio.cpp. Englische Songtexte werden empfohlen. Ein kurzer Test beginnt mit höchstens 400 semantischen Tokens; dies ist keine feste Dauer in Sekunden.</p><p id="music-state" role="status"></p><p id="music-error" role="alert"></p><div id="music-progress"></div><form id="music-form"><label>Profil<select id="music-profile" required></select></label><label>Musikstil<textarea id="music-style" maxlength="2000" required placeholder="English, acoustic pop, warm vocals, gentle guitar"></textarea></label><label>Songtext<textarea id="music-lyrics" maxlength="8000" rows="6" required placeholder="[Verse] … [Chorus] …"></textarea></label><details><summary>Testparameter</summary><label>Maximale semantische Tokens<input type="number" id="music-tokens" min="32" max="9000" value="400"></label><label>NAR-Schritte<input type="number" id="music-steps" min="1" max="32" value="8"></label><label>Seed<input type="number" id="music-seed" min="0" max="2147483647" value="1234"></label></details><button id="music-start">Musik erzeugen</button><button type="button" class="secondary" id="music-cancel">Abbrechen</button></form><div id="music-result"></div><p>RTX 5080 bevorzugt. Deck-Aufträge werden koordiniert. Nach der Anfrage wird das Modell entladen. Text und Stil werden nicht dauerhaft gespeichert.</p></section>';} function html(){return '<section id="music-test" class="card"><h2>Musik ausprobieren</h2><p>YuE2 über audio.cpp. Englische Songtexte werden empfohlen. Ein kurzer Test beginnt mit höchstens 400 semantischen Tokens; dies ist keine feste Dauer in Sekunden.</p><p id="music-state" role="status"></p><p id="music-error" role="alert"></p><div id="music-progress"></div><form id="music-form"><label>Profil<select id="music-profile" required></select></label><label>Musikstil<textarea id="music-style" maxlength="2000" required placeholder="English, acoustic pop, warm vocals, gentle guitar"></textarea></label><label>Songtext<textarea id="music-lyrics" maxlength="8000" rows="6" required placeholder="[Verse] … [Chorus] …"></textarea></label><details><summary>Testparameter</summary><label>Maximale semantische Tokens<input type="number" id="music-tokens" min="32" max="9000" value="400"></label><label>NAR-Schritte<input type="number" id="music-steps" min="1" max="32" value="8"></label><label>Seed<input type="number" id="music-seed" min="0" max="2147483647" value="1234"></label></details><button id="music-start">Musik erzeugen</button><button type="button" class="secondary" id="music-cancel">Abbrechen</button></form><div id="music-result"></div><p>RTX 5080 bevorzugt. Deck-Aufträge werden koordiniert. Nach der Anfrage wird das Modell entladen. Text und Stil werden nicht dauerhaft gespeichert.</p></section>';}
function bind(){const root=document.querySelector('#music-test'),el=id=>root.querySelector('#'+id);let ready=false,busy=false; function bind(){const root=document.querySelector('#music-test'),el=id=>root.querySelector('#'+id);let ready=false,busy=false;
async function poll(){if(!root.isConnected)return;try{const s=await api('music');if(!root.isConnected)return;const j=s.job,running=j?.state==='running';el('music-state').textContent=(j?.phase||'Profil wählen und Musikstil sowie Songtext eingeben.')+(running?' · '+Math.round((Date.now()/1000)-j.started_at)+' s':'');el('music-progress').innerHTML=running?'<progress aria-label="Musikgenerierung läuft"></progress>':'';el('music-start').disabled=busy||running||!ready;el('music-cancel').disabled=!running;if(j?.state==='complete')el('music-result').innerHTML=`<h3>Ergebnis · ${e(j.duration_seconds)} Sekunden</h3><audio controls src="/api/v1/music/audio?id=${e(j.id)}"></audio><p><a download="athena-musik.wav" href="/api/v1/music/audio?id=${e(j.id)}">WAV herunterladen</a></p>`;if(j?.state==='failed')el('music-error').textContent=j.phase;}catch(x){if(root.isConnected)el('music-error').textContent=x.message;}if(root.isConnected)setTimeout(poll,1500);} async function poll(){if(!root.isConnected)return;try{const s=await api('music');if(!root.isConnected)return;const j=s.job,running=j?.state==='running';el('music-state').textContent=(s.mode!=='music'?'Bitte in der Übersicht auf Musik umschalten.':j?.phase||'Profil wählen und Musikstil sowie Songtext eingeben.')+(running?' · '+Math.round((Date.now()/1000)-j.started_at)+' s':'');el('music-progress').innerHTML=running?'<progress aria-label="Musikgenerierung läuft"></progress>':'';el('music-start').disabled=busy||running||!ready||s.mode!=='music';el('music-cancel').disabled=!running;if(j?.state==='complete')el('music-result').innerHTML=`<h3>Ergebnis · ${e(j.duration_seconds)} Sekunden</h3><audio controls src="/api/v1/music/audio?id=${e(j.id)}"></audio><p><a download="athena-musik.wav" href="/api/v1/music/audio?id=${e(j.id)}">WAV herunterladen</a></p>`;if(j?.state==='failed')el('music-error').textContent=j.phase;}catch(x){if(root.isConnected)el('music-error').textContent=x.message;}if(root.isConnected)setTimeout(poll,1500);}
api('profiles').then(s=>{if(!root.isConnected)return;const profiles=s.profiles.filter(p=>p.kind==='music'&&p.runnable);ready=profiles.length>0;el('music-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst Profil und Komponenten einrichten</option>';}).catch(x=>el('music-error').textContent=x.message); api('profiles').then(s=>{if(!root.isConnected)return;const profiles=s.profiles.filter(p=>p.kind==='music'&&p.runnable);ready=profiles.length>0;el('music-profile').innerHTML=profiles.map(p=>`<option value="${e(p.id)}">${e(p.name)}</option>`).join('')||'<option value="">Zuerst Profil und Komponenten einrichten</option>';}).catch(x=>el('music-error').textContent=x.message);
el('music-form').onsubmit=async event=>{event.preventDefault();busy=true;el('music-start').disabled=true;el('music-error').textContent='';el('music-result').replaceChildren();try{await api('music/start',{profile_id:el('music-profile').value,lyrics:el('music-lyrics').value,style:el('music-style').value,seed:Number(el('music-seed').value),max_tokens:Number(el('music-tokens').value),steps:Number(el('music-steps').value)});}catch(x){el('music-error').textContent=x.message;}finally{busy=false;}};el('music-cancel').onclick=()=>api('music/cancel',{}).catch(x=>el('music-error').textContent=x.message);poll();} el('music-form').onsubmit=async event=>{event.preventDefault();busy=true;el('music-start').disabled=true;el('music-error').textContent='';el('music-result').replaceChildren();try{await api('music/start',{profile_id:el('music-profile').value,lyrics:el('music-lyrics').value,style:el('music-style').value,seed:Number(el('music-seed').value),max_tokens:Number(el('music-tokens').value),steps:Number(el('music-steps').value)});}catch(x){el('music-error').textContent=x.message;}finally{busy=false;}};el('music-cancel').onclick=()=>api('music/cancel',{}).catch(x=>el('music-error').textContent=x.message);poll();}
return {html,bind}; return {html,bind};
+3 -2
View File
@@ -14,12 +14,13 @@ class Music:
except ValueError:result.append(info['label']+' fehlt oder ist nicht zugeordnet.') except ValueError:result.append(info['label']+' fehlt oder ist nicht zugeordnet.')
return result return result
def status(self): def status(self):
with self.lock:return dict(job=dict(self.job) if self.job else None,loaded=bool(self.process and self.process.poll() is None),runtime_installed=self.runtime.status()['installed']) with self.lock:return dict(job=dict(self.job) if self.job else None,loaded=bool(self.process and self.process.poll() is None),mode=self.scheduler.gpu_mode,runtime_installed=self.runtime.status()['installed'])
def start(self,profile_id,lyrics,style,seed=1234,max_tokens=400,steps=8,wait=False): def start(self,profile_id,lyrics,style,seed=1234,max_tokens=400,steps=8,wait=False):
if not isinstance(lyrics,str) or not 1<=len(lyrics.strip())<=8000:raise ValueError('Songtext mit 1–8000 Zeichen eingeben.') if not isinstance(lyrics,str) or not 1<=len(lyrics.strip())<=8000:raise ValueError('Songtext mit 1–8000 Zeichen eingeben.')
if not isinstance(style,str) or not 1<=len(style.strip())<=2000:raise ValueError('Musikstil mit 1–2000 Zeichen eingeben.') if not isinstance(style,str) or not 1<=len(style.strip())<=2000:raise ValueError('Musikstil mit 1–2000 Zeichen eingeben.')
for value,lo,hi in ((seed,0,2**31-1),(max_tokens,32,9000),(steps,1,32)): for value,lo,hi in ((seed,0,2**31-1),(max_tokens,32,9000),(steps,1,32)):
if type(value) is not int or not lo<=value<=hi:raise ValueError('Ungültige Musikparameter.') if type(value) is not int or not lo<=value<=hi:raise ValueError('Ungültige Musikparameter.')
if self.scheduler.gpu_mode!='music':raise ValueError('Bitte in der Übersicht auf Musik umschalten.')
with self.lock: with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Musikauftrag läuft bereits.') if self.job and self.job['state']=='running':raise ValueError('Musikauftrag läuft bereits.')
p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='music'),None) p=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='music'),None)
@@ -30,7 +31,7 @@ class Music:
def _run(self,p,lyrics,style,seed,max_tokens,steps,wait): def _run(self,p,lyrics,style,seed,max_tokens,steps,wait):
release=None;state='failed';phase='Musikgenerierung fehlgeschlagen.';directory=self.root/self.job['id'];log=directory/'worker.tmp' release=None;state='failed';phase='Musikgenerierung fehlgeschlagen.';directory=self.root/self.job['id'];log=directory/'worker.tmp'
try: try:
release=self.scheduler.image_reservation(wait=wait) release=self.scheduler.image_reservation(wait=wait,kind="music")
if self.cancel.is_set():raise InterruptedError() if self.cancel.is_set():raise InterruptedError()
required=int(p['model']['size']/1024**2)+1800 required=int(p['model']['size']/1024**2)+1800
gpu=next((g for g in probe() if 'RTX 5080' in g['name'] and not g['processes'] and g['free_mib']>required),None) gpu=next((g for g in probe() if 'RTX 5080' in g['name'] and not g['processes'] and g['free_mib']>required),None)
+2 -1
View File
@@ -127,6 +127,7 @@ class Server(ThreadingHTTPServer):
self.ltx_original_runtime=LTXOriginalRuntime(self.catalog.root.parent/'ltx-original-runtime',self.image_runtime) self.ltx_original_runtime=LTXOriginalRuntime(self.catalog.root.parent/'ltx-original-runtime',self.image_runtime)
self.video=VideoRuntimes(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime,original_runtime=self.ltx_original_runtime) self.video=VideoRuntimes(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime,original_runtime=self.ltx_original_runtime)
self.endpoint.video=self.video self.endpoint.video=self.video
self.video.music_profiles=lambda:[dict(id=p['id'],name=p['name']) for p in self.endpoint.rows() if p['kind']=='music' and p['enabled'] and p['runnable']]
self.profiles.video_runtime_ready=lambda:self.image_runtime.status().get("installed",False) self.profiles.video_runtime_ready=lambda:self.image_runtime.status().get("installed",False)
from dashboard_data import Dashboard from dashboard_data import Dashboard
self.dashboard=Dashboard(self,self.catalog.root.parent) self.dashboard=Dashboard(self,self.catalog.root.parent)
@@ -520,7 +521,7 @@ class Handler(BaseHTTPRequestHandler):
data=self.read_json() data=self.read_json()
if self.path.endswith('/runtime') and set(data)=={'runtime'}:return self.respond(self.server.video.select_runtime(data['runtime'])) if self.path.endswith('/runtime') and set(data)=={'runtime'}:return self.respond(self.server.video.select_runtime(data['runtime']))
if self.path.endswith('/service') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id'])) if self.path.endswith('/service') and set(data)=={'id'}:return self.respond(self.server.video.select(data['id']))
if set(data)!={'mode'}:raise ValueError('Nur Modus llm oder video erwartet.') if set(data)!={'mode'}:raise ValueError('Nur Modus llm, video oder music erwartet.')
return self.respond(self.server.video.switch(data['mode'])) return self.respond(self.server.video.switch(data['mode']))
except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400) except (OSError,ValueError) as exc:return self.respond({'error':str(exc)},400)
if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'): if self.path in ('/api/v1/endpoint/start','/api/v1/endpoint/stop','/api/v1/endpoint/config','/api/v1/endpoint/profile'):
+3
View File
@@ -67,6 +67,9 @@ class EndpointTests(unittest.TestCase):
self.enable('music') self.enable('music')
self.assertEqual(self.request('/v1/audio/music/models')[1]['data'][0]['id'],'Music') self.assertEqual(self.request('/v1/audio/music/models')[1]['data'][0]['id'],'Music')
self.assertNotIn('Music',[x['id'] for x in self.request()[1]['data']]) self.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.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})
response=conn.getresponse();self.assertEqual(response.status,200);self.assertEqual(response.getheader('Content-Type'),'audio/wav');self.assertEqual(response.read(),b'RIFFsynthetic-WAV');conn.close() response=conn.getresponse();self.assertEqual(response.status,200);self.assertEqual(response.getheader('Content-Type'),'audio/wav');self.assertEqual(response.read(),b'RIFFsynthetic-WAV');conn.close()
+1 -1
View File
@@ -15,7 +15,7 @@ class MusicTests(unittest.TestCase):
profiles=Mock();profiles.status.return_value=dict(profiles=[p]);profiles._component.side_effect=lambda model,role,id:entries[id] profiles=Mock();profiles.status.return_value=dict(profiles=[p]);profiles._component.side_effect=lambda model,role,id:entries[id]
cli=root/'cli';cli.write_text('#!/usr/bin/env python3\nimport sys,wave\np=sys.argv[sys.argv.index("--out")+1]\nwith wave.open(p,"wb") as w:w.setparams((1,2,8000,0,"NONE","none"));w.writeframes(bytes(1600))\n');cli.chmod(0o755) cli=root/'cli';cli.write_text('#!/usr/bin/env python3\nimport sys,wave\np=sys.argv[sys.argv.index("--out")+1]\nwith wave.open(p,"wb") as w:w.setparams((1,2,8000,0,"NONE","none"));w.writeframes(bytes(1600))\n');cli.chmod(0o755)
runtime=Mock();runtime.status.return_value=dict(installed=True);runtime.paths.return_value=(cli,root) runtime=Mock();runtime.status.return_value=dict(installed=True);runtime.paths.return_value=(cli,root)
scheduler=Mock();release=Mock();scheduler.image_reservation.return_value=release scheduler=Mock();scheduler.gpu_mode='music';release=Mock();scheduler.image_reservation.return_value=release
return Music(root/'jobs',profiles,catalog,runtime,scheduler),release,p return Music(root/'jobs',profiles,catalog,runtime,scheduler),release,p
def wait(self,m): def wait(self,m):
for _ in range(80): for _ in range(80):
+12
View File
@@ -44,3 +44,15 @@ class VideoComfyTests(unittest.TestCase):
self.assertTrue(self.video.service_authenticated('Bearer synthetic-service-secret')) self.assertTrue(self.video.service_authenticated('Bearer synthetic-service-secret'))
self.assertFalse(self.video.service_authenticated('Bearer wrong')) self.assertFalse(self.video.service_authenticated('Bearer wrong'))
self.assertFalse(self.video.service_authenticated('Bearer ü')) self.assertFalse(self.video.service_authenticated('Bearer ü'))
def test_music_switch_is_exclusive_and_returns_to_llm(self):
import threading
from types import SimpleNamespace
scheduler=SimpleNamespace(gpu_mode='llm',cv=threading.Condition(),active=0,tts_active=0,transition=False,key=('chat',))
self.video.scheduler=scheduler;self.video._stop=Mock();self.video._foreign_services=lambda:[]
self.video.music_profiles=lambda:[dict(id='music',name='YuE2')]
self.video.switch('music');self.video.thread.join(2)
self.assertEqual(scheduler.gpu_mode,'music');self.assertEqual(self.video.state,'ready');self.video.stop_owned.assert_called_once();self.video._stop.assert_called_once()
self.video.switch('llm');self.video.thread.join(2);self.assertEqual(scheduler.gpu_mode,'llm')
def test_music_requires_enabled_ready_profile(self):
with self.assertRaisesRegex(ValueError,'Musikprofil'):self.video.switch('music')
+1 -1
View File
@@ -3,7 +3,7 @@ window.VideoUI=(()=>{
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;} async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const s=await r.json();if(!r.ok)throw Error(s.error||'Anfrage fehlgeschlagen');return s;}
function mode(root,s,profiles=[],port=null){ function mode(root,s,profiles=[],port=null){
if(root.dataset.busy==='1')return; if(root.dataset.busy==='1')return;
root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: ${s.runtime_kind==='original'?'originale LTX-API':'ComfyUI-API'}</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><h3>${e(s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.service?.api_url?`<p>Backend: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>${s.service.ready&&port?`<a class="link" target="_blank" rel="noopener" href="${e(location.protocol+"//"+location.hostname+":"+(s.runtime_kind==='original'?8118:port)+"/")}">${s.runtime_kind==='original'?'LTX DeskWEB öffnen':'ComfyUI öffnen'} →</a>`:""}`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a><div class="video-message" role="status"></div></section>`; root.innerHTML=`<section class="card"><h2>GPU-Modus</h2><p>Gemeinsamer API-Port: <strong>${e(port||"siehe Endpunkt")}</strong> · LLM-Modus: OpenAI-API · Video-Modus: ${s.runtime_kind==='original'?'originale LTX-API':'ComfyUI-API'}</p><button data-mode="llm" ${s.mode==='llm'||s.state==='switching'?'disabled':''}>LLM</button><button data-mode="video" ${!s.service?.id||s.mode==='video'||s.state==='switching'?'disabled':''}>Video</button><button data-mode="music" ${!s.music_profiles?.length||s.mode==='music'||s.state==='switching'?'disabled':''}>Musik</button><h3>${e(s.mode==='music'?s.music_profiles?.map(p=>p.name).join(', '):s.service?.name||'Kein Videodienst eingerichtet')}</h3>${s.state==='switching'?`<div class="video-activity" role="status"><strong>${e(s.switch_phase)}</strong><progress></progress></div>`:`<p>${s.mode==='music'?'Musik bereit · Gewichte laden bei Anfrage':s.mode==='llm'?'LLM-Betrieb · Videodienst gestoppt':s.service?.ready?'Video-API bereit':s.service?.running?'Videodienst läuft · API noch nicht bereit':'Dienststatus nicht prüfbar · GPU-Aufträge gesperrt'}</p>`}${s.mode!=='music'&&s.service?.api_url?`<p>Backend: <code>${e(s.service.api_url)}</code></p><p>${e(s.service.message)}</p>${s.service.ready&&port?`<a class="link" target="_blank" rel="noopener" href="${e(location.protocol+"//"+location.hostname+":"+(s.runtime_kind==='original'?8118:port)+"/")}">${s.runtime_kind==='original'?'LTX DeskWEB öffnen':'ComfyUI öffnen'} →</a>`:""}`:''}${s.error?`<p class="video-error" role="alert">${e(s.error)}</p>`:''}<p>Modelle, Auflösung, Dauer und Generierung werden in der jeweiligen Video-Anwendung verwaltet. Video beendet Deck-GPU-Aufträge; fremde Dienste werden nicht beendet.</p><a href="#video">Aktiven Videodienst im Bereich Video wählen</a>${!s.music_profiles?.length?'<p>Für Musik zuerst unter <a href="#music">Musik → Profile</a> ein ausführbares Profil am API-Endpunkt freigeben.</p>':''}<div class="video-message" role="status"></div></section>`;
root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next,profiles,port);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}}); root.querySelectorAll('[data-mode]').forEach(b=>b.onclick=async()=>{root.dataset.busy='1';root.querySelectorAll('button').forEach(x=>x.disabled=true);root.querySelector('.video-message').textContent='Moduswechsel wird angefordert …';try{const next=await api('video/mode',{mode:b.dataset.mode});root.dataset.busy='0';mode(root,next,profiles,port);}catch(x){root.querySelector('.video-message').textContent=x.message;root.dataset.busy='0';}});
} }
function services(root){ function services(root){
+12 -5
View File
@@ -55,11 +55,12 @@ class VideoComfy:
self.state='failed';self.error='ComfyUI wurde unerwartet beendet. Auf LLM wechseln und Laufzeit erneut starten.' self.state='failed';self.error='ComfyUI wurde unerwartet beendet. Auf LLM wechseln und Laufzeit erneut starten.'
rows=self.models();ident=self.selection.get('model_id');row=next((x for x in rows if x['model']['id']==ident),None) rows=self.models();ident=self.selection.get('model_id');row=next((x for x in rows if x['model']['id']==ident),None)
service=dict(id=ident,name=row['model']['file'] if row else 'Kein Videomodell ausgewählt',running=running,ready=running and self.state=='ready',api_url='/ (ComfyUI auf dem gemeinsamen API-Port)',message='5080: Diffusion/VAE · 3060: Textencoder · große BF16-Gewichte nutzen CPU-Auslagerung.') if row else {} service=dict(id=ident,name=row['model']['file'] if row else 'Kein Videomodell ausgewählt',running=running,ready=running and self.state=='ready',api_url='/ (ComfyUI auf dem gemeinsamen API-Port)',message='5080: Diffusion/VAE · 3060: Textencoder · große BF16-Gewichte nutzen CPU-Auslagerung.') if row else {}
return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status()) return dict(mode=self.scheduler.gpu_mode,state=self.state,error=self.error,target_mode=self.target_mode,switch_phase=self.switch_phase,switch_started_at=self.switch_started_at,service=service,music_profiles=getattr(self,'music_profiles',lambda:[])(),services=[dict(id=x['model']['id'],name=x['model']['file'],api_url=x['label'],**{k:x[k] for k in ('blockers','runnable')}) for x in rows],models=rows,selected=ident,memory_policy='Gemeinsame ComfyUI-Installation. Video startet einen eigenen Prozess; Gewichte laden bei Workflow-Anfrage. Der Wechsel auf LLM beendet den Prozess.',runtime=self.runtime.status())
def switch(self,mode): def switch(self,mode):
if mode not in ('llm','video'):raise ValueError('Modus muss llm oder video sein.') if mode not in ('llm','video','music'):raise ValueError('Modus muss llm, video oder music sein.')
with self.lock: with self.lock:
if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.') if self.state=='switching':raise ValueError('Moduswechsel läuft bereits.')
if mode=='music' and not getattr(self,'music_profiles',lambda:[])():raise ValueError('Zuerst ein ausführbares Musikprofil am API-Endpunkt freigeben.')
if mode=='video': if mode=='video':
row=next((x for x in self.models() if x['model']['id']==self.selection.get('model_id')),None) row=next((x for x in self.models() if x['model']['id']==self.selection.get('model_id')),None)
if not row:raise ValueError('Zuerst Videomodell aktivieren.') if not row:raise ValueError('Zuerst Videomodell aktivieren.')
@@ -70,8 +71,13 @@ class VideoComfy:
return self.status() return self.status()
def _switch(self,mode): def _switch(self,mode):
try: try:
if mode=='llm': self.stop_owned();self._stop();deadline=time.monotonic()+60
self._stop() with self.scheduler.cv:
while self.scheduler.active or self.scheduler.tts_active or self.scheduler.transition:
if time.monotonic()>deadline:raise ValueError('Deck-Aufträge noch aktiv.')
self.scheduler.cv.wait(.2)
self.scheduler.key=None
if mode in ('llm','music'):
if any(x.get('running') is not False for x in self._foreign_services()):raise ValueError('Fremder Videodienst blockiert LLM-Modus.') if any(x.get('running') is not False for x in self._foreign_services()):raise ValueError('Fremder Videodienst blockiert LLM-Modus.')
self.foreign_blocked=False self.foreign_blocked=False
else: else:
@@ -86,7 +92,8 @@ class VideoComfy:
self.scheduler.key=None self.scheduler.key=None
self.switch_phase='Freie GPUs prüfen';self._start() self.switch_phase='Freie GPUs prüfen';self._start()
with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all() with self.scheduler.cv:self.scheduler.gpu_mode=mode;self.scheduler.cv.notify_all()
self.state='ready' if mode=='video' else 'idle' self.state='ready' if mode in ('video','music') else 'idle'
if mode=='music':self.switch_phase='Musik bereit · Gewichte laden bei Anfrage'
except Exception as exc: except Exception as exc:
self._stop();self.error=str(exc);self.state='failed' self._stop();self.error=str(exc);self.state='failed'
with self.scheduler.cv:self.scheduler.gpu_mode='video' if self.foreign_blocked else 'llm';self.scheduler.cv.notify_all() with self.scheduler.cv:self.scheduler.gpu_mode='video' if self.foreign_blocked else 'llm';self.scheduler.cv.notify_all()