diff --git a/docs/RUNTIME_DISCOVERY.md b/docs/RUNTIME_DISCOVERY.md index 36bf63f..e7df274 100644 --- a/docs/RUNTIME_DISCOVERY.md +++ b/docs/RUNTIME_DISCOVERY.md @@ -15,3 +15,7 @@ Eine vorhandene Laufzeit wird wiederverwendet. Ein fehlender Worker oder Workflo Installation liegt im Deck-Zustandsverzeichnis unter `audio-cpp-runtime`. Status, Abbruch und eigene Build-Ausgabe sind in der GUI verfügbar. Der CLI-Start wird ohne Modell geprüft. Dieser Build lädt **keine Gewichte**. Der ausführbare Deck-Musikworker und die Ladypoly-WebUI-Anbindung fehlen noch; sie sind separat umzusetzen. Auch andere audio.cpp-Familien brauchen geprüfte modellspezifische Anbindungen. Interne Admin-API: `GET /api/v1/audio-cpp-runtime`, `GET /api/v1/audio-cpp-runtime/log`, `POST /api/v1/audio-cpp-runtime/install` und `/cancel` (JSON `{}`, vorhandene Sitzung und CSRF-Header). Backup/Restore enthält das gepinnte Laufzeitrezept und baut es bei Bedarf neu; keine Binärdateien im Backup. + +## YuE2-Komponenten in der GUI + +Unter **Musik → Profile → Komponenten** bietet Deck für das offizielle YuE2-GGUF-Hauptmodell fünf Rollen: VAE (F16 empfohlen oder F32), Modellkonfiguration, Generierungskonfiguration, Qwen-Tokenizer und VAE-Konfiguration. „Alle fehlenden Dateien herunterladen“ reiht je eine Datei pro Rolle in die Download-Warteschlange ein. Danach „Bibliothek aktualisieren“ und „Zuordnung auf Athena speichern“. Bereits vorhandene Dateien werden wiederverwendet. Quelle und Zuweisung müssen dieselbe Hub-Revision wie das Hauptmodell haben. Diese Zusatzdateien sind keine eigenständigen Modelle. Der fehlende Musikworker bleibt auch nach vollständiger Zuweisung als Blocker sichtbar. diff --git a/profiles-ui.js b/profiles-ui.js index 65b1709..2b76a49 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -7,7 +7,7 @@ window.ProfilesUI=(()=>{ const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],entries=[],panelSequence=0; const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length; - el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}

${kind==='image'?`

Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}

`:''}${CatalogUI.executionHTML(p.execution)}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${['image','video'].includes(kind)?``:''}${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}

${kind==='image'?`

Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}

`:''}${CatalogUI.executionHTML(p.execution)}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${['image','video','music'].includes(kind)?``:''}${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; el('profile-list').insertAdjacentHTML('afterbegin',`

${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.

`); root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}}); root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}}); @@ -36,7 +36,7 @@ window.ProfilesUI=(()=>{ el('profile-editor').innerHTML='

Komponenten werden geprüft …

'; try{ const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id}));if(!root.isConnected||sequence!==panelSequence)return; - el('profile-editor').innerHTML=`

Komponenten · ${e(profile.name)}

${e(data.message)}

${data.supported?`

Quelle: Modellkarte und Lizenz ↗ · ${e(data.license||'Lizenz nicht angegeben')}

${profile.kind==='image'?'Bildlaufzeit → · ':'Videolaufzeit · '}${e(data.runtime)} · ${data.runtime_installed?'installiert':'noch nicht eingerichtet'}

${data.requirements.map(r=>`

${e(r.label)}

${r.candidates.map((f,i)=>`

${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}

${f.gated&&!f.auth_configured?'

Hugging-Face-Zugang unter Einstellungen → Zugang hinterlegen und Modellfreigabe prüfen.

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

Keine passende Quelldatei verfügbar.

'}
`).join('')}

Lade je eine Datei pro benötigter Komponente. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.

`:''}

`; + el('profile-editor').innerHTML=`

Komponenten · ${e(profile.name)}

${e(data.message)}

${data.supported?`

Quelle: Modellkarte und Lizenz ↗ · ${e(data.license||'Lizenz nicht angegeben')}

${profile.kind==='image'?'Bildlaufzeit → · ':profile.kind==='music'?'Musiklaufzeit → · ':'Videolaufzeit · '}${e(data.runtime)} · ${data.runtime_installed?'installiert':'noch nicht eingerichtet'}

${data.requirements.map(r=>`

${e(r.label)}

${r.candidates.map((f,i)=>`

${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB · Version ${e(f.revision.slice(0,12))}

${f.gated&&!f.auth_configured?'

Hugging-Face-Zugang unter Einstellungen → Zugang hinterlegen und Modellfreigabe prüfen.

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

Keine passende Quelldatei verfügbar.

'}
`).join('')}

Lade je eine Datei pro benötigter Komponente. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.

`:''}

`; const missing=data.requirements?.filter(r=>!r.available.length)||[]; if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{ const b=el('components-download-all');b.disabled=true;let count=0; diff --git a/profiles.py b/profiles.py index 46967cd..36cd917 100644 --- a/profiles.py +++ b/profiles.py @@ -78,8 +78,19 @@ LTX_COMPONENTS={ def video_recipe(model): return LTX_COMPONENTS if model.get('kind')=='video' and model.get('repo')==LTX_REPO and model.get('file')==LTX_FILE else None +YUE2_REPO='audio-cpp/Yue2-3B-GGUF' +YUE2_COMPONENTS={ + 'vae':{'label':'YuE2-VAE · F16 empfohlen, F32 alternativ','files':['yue2-vae-f16.gguf','yue2-vae-f32.gguf']}, + 'model_config':{'label':'Modellkonfiguration','files':['sidecars/yue2-model-config.json']}, + 'generation_config':{'label':'Generierungskonfiguration','files':['sidecars/yue2-generation-config.json']}, + 'tokenizer':{'label':'Qwen-Tokenizer','files':['sidecars/yue2-qwen.tiktoken']}, + 'vae_config':{'label':'VAE-Konfiguration','files':['sidecars/yue2-vae-config.json']} +} +def music_recipe(model): + return YUE2_COMPONENTS if model.get('kind')=='music' and model.get('repo')==YUE2_REPO and model.get('file') in ('yue2-3b-bf16.gguf','yue2-3b-q8_0.gguf','yue2-3b-q4_0.gguf') else None + def component_recipe(model): - return video_recipe(model) or image_recipe(model) + return music_recipe(model) or video_recipe(model) or image_recipe(model) class Profiles: def __init__(self,path,catalog): @@ -111,6 +122,10 @@ class Profiles: for role,info in video_recipe(p['model']).items(): try:self._component(p['model'],role,p.get('components',{}).get(role)) except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.') + if p.get('model') and music_recipe(p['model']): + for role,info in music_recipe(p['model']).items(): + try:self._component(p['model'],role,p.get('components',{}).get(role)) + except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.') if p['kind']=='video' and self.video_blockers and p.get('model'):p['blockers']=self.video_blockers(p) if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p) if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p) @@ -191,12 +206,12 @@ class Profiles: if not recipe or role not in recipe:raise ValueError('Für dieses Modell ist kein geprüftes Komponentenrezept hinterlegt.') item=self.catalog.entry(ident) if item['repo']!=recipe[role].get('repo',model['repo']) or item['kind']!=model['kind'] or item['file'] not in recipe[role]['files']:raise ValueError('Die Datei gehört nicht zu dieser Komponente des Modells.') - if video_recipe(model) and item['revision']!=model['revision']:raise ValueError('LTX-Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.') + if (video_recipe(model) or music_recipe(model)) and item['revision']!=model['revision']:raise ValueError('Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.') return item def components(self,model_id): model=self.catalog.entry(model_id) if not component_recipe(model):return dict(supported=False,requirements=[],message='Für dieses Modell ist noch kein Komponentenrezept hinterlegt. Keine automatische Zuordnung.') - recipe=component_recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[] + recipe=component_recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video or music_recipe(model) else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[] for role,info in recipe.items(): available=[] for item in entries: @@ -205,6 +220,7 @@ class Profiles: except ValueError:pass candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name] requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates)) + if music_recipe(model):return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='audio.cpp',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Passendes YuE2-GGUF-Paket: je eine VAE-Datei und die vier Zusatzdateien derselben Quellversion. F16-VAE ist die erste Downloadempfehlung. Vorhandene Dateien wiederverwenden, fehlende herunterladen und danach zuordnen. Der Deck-Musikworker und die Ladypoly-Anbindung fehlen weiterhin; vollständige Komponenten starten kein Modell.') if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='ComfyUI · gemeinsame Bild- und Videolaufzeit',runtime_installed=bool(getattr(self,'video_runtime_ready',lambda:False)()),message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Vorhandene ComfyUI-Laufzeit wiederverwenden; Videomodell im Bereich Video aktivieren. Vollständige Dateien sind keine Speicherzusage.') return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(), message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.') @@ -212,7 +228,7 @@ class Profiles: if set(data)!={'id','revision','components'} or not isinstance(data['components'],dict):raise ValueError('Ungültige Komponentenzuordnung.') with self.lock: old=next((p for p in self.rows if p['id']==data['id']),None) - if not old or old['kind'] not in ('image','video') or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.') + if not old or old['kind'] not in ('image','video','music') or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.') model=self.catalog.entry(old['model_id']) if set(data['components'])-set(component_recipe(model) or {}):raise ValueError('Unbekannte Komponente für dieses Modell.') components={k:v for k,v in data['components'].items() if v} diff --git a/server.py b/server.py index c3b4f58..ff53255 100644 --- a/server.py +++ b/server.py @@ -117,6 +117,7 @@ class Server(ThreadingHTTPServer): from ltx_original_runtime import LTXOriginalRuntime from audio_cpp_runtime import AudioCppRuntime self.audio_cpp_runtime=AudioCppRuntime(self.catalog.root.parent/'audio-cpp-runtime') + self.profiles.music_runtime_ready=lambda:self.audio_cpp_runtime.status()['installed'] self.ltx_original_runtime=LTXOriginalRuntime(self.catalog.root.parent/'ltx-original-runtime',self.image_runtime) self.video=VideoRuntimes(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime,original_runtime=self.ltx_original_runtime) self.endpoint.video=self.video diff --git a/test_music_components.py b/test_music_components.py new file mode 100644 index 0000000..971c9e7 --- /dev/null +++ b/test_music_components.py @@ -0,0 +1,26 @@ +import tempfile,unittest +from pathlib import Path +from unittest.mock import Mock +from profiles import Profiles,YUE2_REPO,YUE2_COMPONENTS,music_recipe +from catalog import file_role +class MusicComponentsTests(unittest.TestCase): + def test_download_recipe_assignment_and_remaining_worker_block(self): + model=dict(id='main',kind='music',repo=YUE2_REPO,file='yue2-3b-bf16.gguf',revision='a'*40) + entries=[dict(id=role,kind='music',repo=YUE2_REPO,file=info['files'][0],revision='a'*40,size=100) for role,info in YUE2_COMPONENTS.items()] + lookup={e['id']:e for e in [model]+entries} + c=Mock();c.entry.side_effect=lambda id:lookup[id] if id in lookup else (_ for _ in ()).throw(ValueError('missing')) + c.status.return_value=dict(entries=entries) + c.files.return_value=dict(repo=YUE2_REPO,revision='a'*40,files=[dict(name=e['file'],size=100) for e in entries],gated=False,license='apache-2.0') + with tempfile.TemporaryDirectory() as d: + p=Profiles(Path(d)/'profiles.json',c);p.music_runtime_ready=lambda:True + p.rows=[dict(id='profile',kind='music',revision=1,name='YuE2',model_id='main',parameters={})] + r=p.components('main');self.assertEqual(len(r['requirements']),5);self.assertTrue(r['runtime_installed']);c.files.assert_called_once_with(YUE2_REPO,'a'*40) + self.assertTrue(all(len(x['available'])==1 for x in r['requirements'])) + p.assign(dict(id='profile',revision=1,components={e['id']:e['id'] for e in entries})) + result=p.status()['profiles'][0];self.assertFalse(result['runnable']);self.assertEqual(len(result['blockers']),1) + entries[0]['revision']='b'*40 + with self.assertRaises(ValueError):p._component(model,'vae','vae') + def test_components_cannot_be_main_models(self): + for info in YUE2_COMPONENTS.values(): + for filename in info['files']:self.assertFalse(file_role(filename)['profile_eligible']) + self.assertIsNone(music_recipe(dict(kind='music',repo='other/Yue2',file='yue2-3b-bf16.gguf')))