diff --git a/VOXCPM2_COMPONENTS.md b/VOXCPM2_COMPONENTS.md new file mode 100644 index 0000000..ec9e4b0 --- /dev/null +++ b/VOXCPM2_COMPONENTS.md @@ -0,0 +1,32 @@ +# VoxCPM2: Komponentenpaket und Ausführungsstatus + +Für `openbmb/VoxCPM2/model.safetensors` besitzt Deck ein eigenes Komponentenrezept: + +- `audiovae.pth`: Audio-VAE-Gewichte. +- `config.json`: Modellkonfiguration einschließlich Audio-VAE-Architektur. +- `tokenizer.json`: Text-Tokenizer/Wortschatz. +- `tokenizer_config.json`: Einstellungen des Text-Tokenizers. +- `special_tokens_map.json`: Spezialtokens. + +Diese Dateien müssen dieselbe feste Repository-Revision wie die Hauptgewichte +verwenden. Vorhandene Downloads werden wiederverwendet. Die zwei Tokenizer-Dateien +sind verschiedene Teile eines Pakets, keine doppelten Modelle. Separater Textencoder +und Vision-Projektor werden nicht benötigt. Der optionale externe Denoiser ist +nicht Bestandteil dieses Grundpakets. + +Das Dateirezept ist anhand des [offiziellen Modells](https://huggingface.co/openbmb/VoxCPM2) +und des [originalen Loaders](https://github.com/OpenBMB/VoxCPM/blob/main/src/voxcpm/model/voxcpm2.py) +geprüft. Es ist keine Zusage getesteter Inferenz: **Die VoxCPM-Python/PyTorch-Laufzeit +und ihr Deck-Worker sind noch nicht angebunden.** Eine vorhandene audio.cpp- oder +llama.cpp-Installation ersetzt diese Anbindung nicht. Das Profil bleibt gesperrt. + +Der Katalog erlaubt `.pth` ausschließlich für die exakt benannte offizielle +Audio-VAE-Datei dieses Repositorys. Der Download führt keine Datei aus. Das spätere +Laden muss wie im Original mit `weights_only=True` erfolgen. Kein automatisches +Ausführen von Python-Dateien aus Modell-Repositories. + +Die Bibliothek gruppiert Text-Tokenizer gesondert; sie erscheinen nicht zusätzlich +als allgemeine Konfiguration. „Alle fehlenden Dateien herunterladen“ prüft zunächst, +ob für alle Pflichtkomponenten eine herunterladbare Datei angeboten wird. Erst danach +werden Downloads eingereiht; bei einem späteren Fehler zeigt Deck bereits eingereihte +Dateien an. Manuelle, ungeprüfte Listen erhalten weiterhin keinen Sammeldownload. diff --git a/catalog-ui.js b/catalog-ui.js index 1e2d885..413fc47 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -33,7 +33,7 @@ window.CatalogUI = (() => { function renderLibrary() { const openRows=new Set([...root.querySelectorAll('.detail-row[open]')].map(x=>x.dataset.rowId)); const entries = state.entries.filter(x => x.kind === kind); - const groups = [['model', kind==='chat'?'Sprachmodelle · Hauptgewichte':'Modelle · Hauptgewichte'],['vision_projector','Vision-Projektoren'],['audio_projector','Audio-Projektoren'],['text_encoder','Textencoder'],['vae','VAE · Decoder'],['configuration','Konfigurationen'],['auxiliary','Tokenizer und weitere Zusatzkomponenten']]; + const groups = [['model', kind==='chat'?'Sprachmodelle · Hauptgewichte':'Modelle · Hauptgewichte'],['vision_projector','Vision-Projektoren'],['audio_projector','Audio-Projektoren'],['text_encoder','Textencoder'],['vae','VAE · Decoder'],['tokenizer','Tokenizer-Dateien'],['configuration','Konfigurationen'],['auxiliary','Weitere Zusatzkomponenten']]; const row = x => `
${escape(x.file)}${escape(x.role_label)}${size(x.size)}${x.used_by_profiles?.length?`${x.used_by_profiles.length} Profil(e)`:'Nicht zugeordnet'}

Quelle: ${escape(x.download_source?.repo||x.repo)}

${x.download_source?`

Lokal übernommen · Originaldatei: ${escape(x.download_source.file)} · Quellrevision: ${escape(x.download_source.revision)}

`:""}

${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${executionHTML(x.execution)}${x.used_by_profiles?.length?`

Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.

`:""}
${x.profile_eligible===true?``:""}
`; const known = new Set(groups.map(([role])=>role)); const sections = [...groups, ['other','Weitere Dateien · Zuordnung unbekannt']]; diff --git a/catalog.py b/catalog.py index 7f9ceb6..07a223c 100644 --- a/catalog.py +++ b/catalog.py @@ -56,12 +56,14 @@ def file_role(filename): role,label='auxiliary','Sprach-Tokenizer' elif any(p in ('text_encoder','text_encoders') for p in parts) or re.search(r'(?:^|[/_-])(?:text.encoder|qwen3vl|t5xxl|clip_l|clip_g)(?:[/_.-]|$)',name): role,label='text_encoder','Textencoder' - elif 'vae' in parts or re.search(r'(?:^|[/_-])vae(?:[/_.-]|$)',name): + elif PurePosixPath(name).name in ('audiovae.pth','audiovae.safetensors') or 'vae' in parts or re.search(r'(?:^|[/_-])vae(?:[/_.-]|$)',name): role,label='vae','VAE' elif any(p in ('latent_upscale_models','model_patches','loras') for p in parts): role,label='auxiliary','Zusatzkomponente' elif re.search(r'(?:^|[/_-])(?:lora|adapter)(?:[/_.-]|$)',name): role,label='auxiliary','Zusatzkomponente' + elif 'tokenizer' in PurePosixPath(name).name or name.endswith('.tiktoken'): + role,label='tokenizer','Tokenizer-Datei' elif name.endswith(('.gguf','.safetensors')): role,label='model','Modelldatei' else: @@ -201,7 +203,8 @@ class Catalog: files=[] for f in data.get('siblings',[]): name=f['rfilename'];p=PurePosixPath(name) - if p.is_absolute() or '..' in p.parts or p.suffix.lower() not in ('.gguf','.safetensors','.json','.tiktoken'):continue + allowed=p.suffix.lower() in ('.gguf','.safetensors','.json','.tiktoken') or (repo=='openbmb/VoxCPM2' and name=='audiovae.pth') + if p.is_absolute() or '..' in p.parts or not allowed:continue size=f.get('size',f.get('lfs',{}).get('size')) if not isinstance(size,int) or size<1:continue files.append(dict(name=name,size=size,sha256=f.get('lfs',{}).get('sha256'))) diff --git a/execution_setup.py b/execution_setup.py index db566dd..ac72535 100644 --- a/execution_setup.py +++ b/execution_setup.py @@ -1,7 +1,7 @@ """Read-only, conservative mapping to Deck's maintained execution adapters.""" from catalog import file_role from stt import supported as stt_supported -from profiles import image_recipe, image_adapter, video_recipe +from profiles import image_recipe, image_adapter, video_recipe, voice_recipe from tts_runtime import REPO, REVISION def _assess(model, installed, profile=None): @@ -21,6 +21,8 @@ def _assess(model, installed, profile=None): elif kind=='audio' and repo==REPO and filename=='model.safetensors' and model.get('revision')==REVISION: result['family']='Qwen3-TTS CustomVoice' result.update(runtime='Qwen3-TTS CustomVoice',route='#tts-runtime',components=['Vollständiges Modellpaket und Sprach-Tokenizer (im Einrichtungsassistenten enthalten)']) + elif voice_recipe(model): + result.update(family='VoxCPM2',evidence='Offizielles OpenBMB-Modellpaket',state='adapter_missing',label='VoxCPM2 erkannt · Ausführung noch nicht angebunden',runtime=None,components=[x['label'] for x in voice_recipe(model).values()],message='Die heruntergeladenen Dateien bilden das Modellpaket. Für die Ausführung fehlt noch die VoxCPM-Python/PyTorch-Laufzeit samt Deck-Worker. audio.cpp und llama.cpp führen diese Safetensors-Datei nicht aus. Textencoder und Vision-Projektor sind nicht separat erforderlich. Unter Komponenten werden die tatsächlich benötigten Dateien angeboten.') elif kind=='chat' and filename.lower().endswith('.gguf'): result.update(runtime='llama.cpp',route='#runtime',state='check',label='Kompatibilität beim Laden prüfen',message='llama.cpp ist der mögliche Ausführungsweg für dieses Chat-GGUF. Architektur und gewählte Optionen müssen vom installierten Build unterstützt werden. Der Testchat prüft die tatsächliche Ausführung.') elif kind=='image' and filename.lower().endswith(('.safetensors','.gguf')) and (meta.get('pipeline_tag') in ('text-to-image','image-to-image') or meta.get('library_name') in ('diffusers','comfyui')): @@ -56,6 +58,8 @@ def assess(model, installed, profile=None): meta=model.get('runtime_metadata') or {};family=str(meta.get('architecture') or '').lower() def add(id,label,key,route,reason,maintained=True): result['candidates'].append(dict(id=id,label=label,installed=bool(installed.get(key)),route=route,installation_available=maintained,evidence=reason)) + if voice_recipe(model): + add('voxcpm','VoxCPM · Python/PyTorch','voxcpm',None,'Offizielle VoxCPM2-Laufzeit; noch kein Deck-Installer oder Worker vorhanden',False) if result.get('runtime'): add('current',result['runtime'],kind,result['route'],'Vorhandene Deck-Zuordnung') if kind=='music' and repo=='audio-cpp/yue2-3b-gguf' and name.startswith('yue2-3b-') and name.endswith('.gguf'): diff --git a/profiles-ui.js b/profiles-ui.js index f7af383..f32d250 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -38,11 +38,11 @@ window.ProfilesUI=(()=>{ try{ const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id,...(extraRepo?{repo:extraRepo}:{})}));if(!root.isConnected||sequence!==panelSequence)return; el('profile-editor').innerHTML=`

Komponenten · ${e(profile.name)}

${e(data.message)}

Andere Komponentenquelle auswählen (ungeprüft)
${data.supported?`

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

Laufzeit: ${e(data.runtime)} · ${data.manual?'Zuordnung ungeprüft':data.runtime_installed?'installiert':'noch nicht eingerichtet'}

${!data.manual?'':'

Nur benötigte Dateien einzeln auswählen; diese Liste ist kein automatischer Downloadplan.

'}
${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)||[]; + const missing=data.requirements?.filter(r=>!r.available.length&&!r.optional)||[]; 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; - try{for(const r of missing){const f=r.candidates.find(f=>!f.gated||f.auth_configured);if(!f)throw Error('Keine herunterladbare Datei; HF-Zugang oder Quellversion prüfen für '+r.label);const catalog=await api('catalog');if((catalog.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===f.repo&&j.file===f.name&&j.revision===f.revision))continue;await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});count++;}if(el('components-message'))el('components-message').textContent='Dateien sind im Downloadplan. Nach Abschluss Bibliothek aktualisieren und Zuordnung speichern; passende Dateien werden vorausgewählt.';} - catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;} + try{const plan=missing.map(r=>{const f=r.candidates.find(f=>!f.gated||f.auth_configured);if(!f)throw Error('Downloadplan unvollständig: '+r.label+'. Quelle oder HF-Zugang prüfen. Es wurde noch kein Download gestartet.');return f;});for(const f of plan){const catalog=await api('catalog');if((catalog.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===f.repo&&j.file===f.name&&j.revision===f.revision))continue;await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});count++;}if(el('components-message'))el('components-message').textContent='Dateien sind im Downloadplan. Nach Abschluss Bibliothek aktualisieren und Zuordnung speichern; passende Dateien werden vorausgewählt.';} + catch(error){if(el('components-message'))el('components-message').textContent=(count?count+' Datei(en) bereits im Downloadplan. ':'')+error.message;b.disabled=false;} };} el('component-source-open').onclick=()=>{const value=el('component-source').value.trim();if(value)components(profile,value);}; el('components-close').onclick=()=>el('profile-editor').replaceChildren(); diff --git a/profiles.py b/profiles.py index bba672d..9faec5a 100644 --- a/profiles.py +++ b/profiles.py @@ -109,8 +109,19 @@ YUE2_COMPONENTS={ 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 +VOXCPM2_REPO='openbmb/VoxCPM2' +VOXCPM2_COMPONENTS={ + 'vae':dict(label='Audio-VAE · erzeugt die Audiodatei',files=['audiovae.pth']), + 'model_config':dict(label='Modellkonfiguration',files=['config.json']), + 'tokenizer':dict(label='Text-Tokenizer · Wortschatz',files=['tokenizer.json']), + 'tokenizer_config':dict(label='Tokenizer-Einstellungen · gehört zum Wortschatz',files=['tokenizer_config.json']), + 'special_tokens':dict(label='Spezialtokens · Steuerzeichen des Tokenizers',files=['special_tokens_map.json']) +} +def voice_recipe(model): + return VOXCPM2_COMPONENTS if model.get('kind') in ('voice','audio') and model.get('repo')==VOXCPM2_REPO and model.get('file')=='model.safetensors' else None + def component_recipe(model): - return music_recipe(model) or video_recipe(model) or image_recipe(model) + return voice_recipe(model) or music_recipe(model) or video_recipe(model) or image_recipe(model) MANUAL_ROLES={'manual_text_encoder':'Textencoder (ungeprüft)','manual_vae':'VAE (ungeprüft)','manual_tokenizer':'Tokenizer (ungeprüft)','manual_configuration':'Konfiguration (ungeprüft)','manual_projector':'Projektor (ungeprüft)','manual_auxiliary':'Weitere Komponente (ungeprüft)'} @@ -161,8 +172,13 @@ class Profiles: 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.get('model') and voice_recipe(p['model']): + p['blockers']=['VoxCPM2-Ausführung fehlt: Die VoxCPM-Laufzeit und der Deck-Worker sind noch nicht angebunden.'] + for role,info in voice_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']=='audio' and self.tts_blockers and p.get('model') and not voice_recipe(p['model']):p['blockers']=self.tts_blockers(p) if p['kind']=='music' and getattr(self,'music_blockers',None) and p.get('model'):p['blockers']=self.music_blockers(p) if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p) if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p) @@ -247,12 +263,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) or music_recipe(model)) and item['revision']!=model['revision']:raise ValueError('Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.') + if (video_recipe(model) or music_recipe(model) or voice_recipe(model)) and item['revision']!=model['revision']:raise ValueError('Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.') return item def components(self,model_id,extra_repo=None): model=self.catalog.entry(model_id) if extra_repo or not self._recipe(model):return self.manual_components(model,extra_repo) - recipe=self._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=[] + recipe=self._recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video or music_recipe(model) or voice_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: @@ -261,6 +277,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 voice_recipe(model):return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='VoxCPM · eigene Python/PyTorch-Laufzeit',runtime_installed=False,worker_available=False,message='VoxCPM2 benötigt genau diese fünf Zusatzdateien derselben Quellversion. Ein separater Textencoder und ein Vision-Projektor werden nicht benötigt. tokenizer.json und tokenizer_config.json gehören zusammen, sie sind keine doppelten Modelle. Vorhandene Dateien werden wiederverwendet. Der VoxCPM-Worker ist in Deck noch nicht angebunden; auch ein vollständiges Dateipaket kann deshalb noch nicht gestartet werden.') 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. Die YuE2-Musikanbindung verwendet diese Dateien über audio.cpp. Speichern startet kein Modell; anschließend Musik → Testen verwenden.') 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.') if not image_recipe(model):return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='audio.cpp · Paketdefinition',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Zusatzdateien aus der installierten Laufzeit-Paketdefinition. Weitere modellabhängige Pakete können erforderlich sein; dies ist keine vollständige Worker-Zusage.') @@ -270,7 +287,7 @@ class Profiles: source=self.catalog.files(repo or model['repo'],None if repo else model['revision']) entries=self.catalog.status()['entries'];requirements=[] for role,label in MANUAL_ROLES.items(): - match={'manual_text_encoder':('text_encoder',),'manual_vae':('vae',),'manual_projector':('vision_projector','audio_projector'),'manual_configuration':('configuration',),'manual_auxiliary':('auxiliary',),'manual_tokenizer':('configuration','auxiliary')}[role] + match={'manual_text_encoder':('text_encoder',),'manual_vae':('vae',),'manual_projector':('vision_projector','audio_projector'),'manual_configuration':('configuration',),'manual_auxiliary':('auxiliary',),'manual_tokenizer':('tokenizer','configuration','auxiliary')}[role] def matches(name):return name.endswith('.tiktoken') or 'tokenizer' in name.lower() if role=='manual_tokenizer' else file_role(name)['role'] in match candidates=[dict(f,repo=source['repo'],revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for f in source['files'] if f['name']!=model['file'] and matches(f['name'])] available=[x for x in entries if x['kind']==model['kind'] and x['id']!=model['id'] and matches(x['file'])] diff --git a/test_voxcpm_components.py b/test_voxcpm_components.py new file mode 100644 index 0000000..badeb70 --- /dev/null +++ b/test_voxcpm_components.py @@ -0,0 +1,34 @@ +import tempfile,unittest +from pathlib import Path +from unittest.mock import Mock,patch +from catalog import Catalog,file_role +from profiles import Profiles,voice_recipe,VOXCPM2_COMPONENTS +from execution_setup import assess +class VoxCPMComponentsTests(unittest.TestCase): + def test_recipe_uses_existing_files_and_requires_matching_revision(self): + model=dict(id='main',kind='voice',repo='openbmb/VoxCPM2',file='model.safetensors',revision='a'*40,size=1) + entries=[dict(id=role,kind='voice',repo=model['repo'],file=info['files'][0],revision=model['revision'],size=1) for role,info in VOXCPM2_COMPONENTS.items()] + c=Mock();lookup={x['id']:x for x in [model]+entries};c.entry.side_effect=lambda id:lookup[id] + c.status.return_value=dict(entries=entries);c.files.return_value=dict(repo=model['repo'],revision=model['revision'],files=[dict(name=x['file'],size=1) for x in entries],gated=False,license='apache-2.0') + with tempfile.TemporaryDirectory() as d: + p=Profiles(Path(d)/'profiles.json',c);p.rows=[dict(id='p',revision=1,name='Vox',kind='voice',model_id='main',parameters={})] + data=p.components('main');c.files.assert_called_once_with(model['repo'],model['revision']) + self.assertEqual(len(data['requirements']),5);self.assertFalse(data['worker_available']);self.assertTrue(all(len(x['available'])==1 for x in data['requirements'])) + p.assign(dict(id='p',revision=1,components={x['id']:x['id'] for x in entries})) + status=p.status()['profiles'][0];self.assertFalse(status['runnable']);self.assertEqual(len(status['blockers']),1);self.assertIn('VoxCPM',status['blockers'][0]) + entries[0]['revision']='b'*40 + with self.assertRaises(ValueError):p._component(model,'vae','vae') + def test_not_applied_to_arbitrary_voices_or_finetunes(self): + for repo,file in [('other/VoxCPM2','model.safetensors'),('openbmb/VoxCPM2','other.safetensors')]:self.assertIsNone(voice_recipe(dict(kind='voice',repo=repo,file=file))) + def test_roles_do_not_offer_auxiliary_files_as_main_models(self): + self.assertEqual(file_role('audiovae.pth')['role'],'vae') + for name in ('tokenizer.json','tokenizer_config.json'): + self.assertEqual(file_role(name)['role'],'tokenizer');self.assertFalse(file_role(name)['profile_eligible']) + def test_catalog_only_allows_reviewed_pth_source(self): + metadata=dict(sha='a'*40,siblings=[dict(rfilename=name,size=10) for name in ['model.safetensors','audiovae.pth','arbitrary.pth','untrusted.py']],cardData={}) + with tempfile.TemporaryDirectory() as d,patch('catalog.metadata',return_value=metadata): + c=Catalog(d);self.assertEqual([f['name'] for f in c.files('openbmb/VoxCPM2')['files']],['model.safetensors','audiovae.pth']) + self.assertEqual([f['name'] for f in c.files('other/VoxCPM2')['files']],['model.safetensors']) + def test_execution_does_not_claim_installed_audio_cpp_can_run_vox(self): + result=assess(dict(kind='voice',repo='openbmb/VoxCPM2',file='model.safetensors'),dict(audio_cpp=True)) + self.assertEqual(result['family'],'VoxCPM2');self.assertEqual(result['state'],'adapter_missing');self.assertEqual(result['candidates'][0]['id'],'voxcpm');self.assertFalse(result['candidates'][0]['installation_available'])