From 7910307cfb514fd6ae2df7c94eeeba1ed6062f6c Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Tue, 29 Sep 2026 12:13:54 +0200
Subject: [PATCH] Add version-bound LTX 2.5 video component recipe
---
README.md | 6 ++++++
catalog.py | 16 ++++++++++------
execution_setup.py | 4 +++-
profiles-ui.js | 8 ++++----
profiles.py | 33 +++++++++++++++++++++++++++------
test_video_components.py | 34 ++++++++++++++++++++++++++++++++++
6 files changed, 84 insertions(+), 17 deletions(-)
create mode 100644 test_video_components.py
diff --git a/README.md b/README.md
index e33847b..1c8d266 100644
--- a/README.md
+++ b/README.md
@@ -148,3 +148,9 @@ Unter **Einstellungen → Zugang → Hugging Face** einen eigenen `hf_…`-Token
Der Token wird ausschließlich serverseitig unter `models/huggingface.json` im Deck-Zustandsverzeichnis gespeichert (0600, unverschlüsselt); Zustandsbackups entsprechend schützen. Er gilt für Katalogabfragen, Modell- und Komponentendownloads aller Kategorien. HTTP-Weiterleitungen auf andere Hosts erhalten keinen Authorization-Header. Entfernen verhindert neue authentifizierte Anfragen, beendet aber keinen bereits autorisierten Download. Download abbrechen bei Bedarf separat nutzen.
Interne API (nur angemeldete Administratorsitzung): `GET /api/v1/huggingface` liefert ausschließlich `{configured: boolean}`; `POST` mit `{token: "hf_…"}` speichert/ersetzt, mit `{token: null}` entfernt. POST benötigt `X-Athena-Deck: 1`. Kein API-Endpunkt liefert den gespeicherten Token zurück. HTTP 401/403 von HF ergeben Hinweise auf Token, Leserechte und Modellfreigabe; die tatsächliche Freigabe wird beim Download geprüft.
+
+### LTX-2.5-Komponentenrezept
+
+Video → Profile → Komponenten unterstützt das offizielle `Lightricks/LTX-2.5`-Modell `diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors`. Das Herstellerrezept für die zweistufige Distilled-Pipeline mit fester Bildanzahl umfasst Gemma 4 12B inklusive LTX-Projektionen, Video-VAE, Audio-VAE/Vocoder und Spatial-Upsampler. Quelle: https://huggingface.co/Lightricks/LTX-2.5 . Metadaten und Downloads sind an den Commit des Hauptmodells gebunden; Zuordnung anderer Revisionen wird abgelehnt. Der Katalog kann dafür feste historische Commits abrufen. Vorhandene Komponenten werden erkannt, fehlende über die normale Warteschlange geladen und anschließend im Profil gespeichert.
+
+Das Rezept unterstützt keine beliebigen Quantisierungen, Comfy-INT8-Encoder oder andere Modellfamilien. Duration-Head, Temporal-Upsampling und DFR sind optional und nicht enthalten. Die Video-Ausführung bleibt gesperrt, solange kein Video-Worker angebunden ist. Vollständige Dateien sind keine Zusage, dass BF16-Modell und Encoder in RAM/VRAM passen. Komponenten werden nicht als eigenständige Modelle angeboten. Keine automatischen Zusatzdownloads beim Öffnen der Ansicht.
diff --git a/catalog.py b/catalog.py
index 3590c7a..6d30ff7 100644
--- a/catalog.py
+++ b/catalog.py
@@ -58,6 +58,8 @@ def file_role(filename):
role,label='text_encoder','Textencoder'
elif '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 name.endswith(('.gguf','.safetensors')):
@@ -154,12 +156,14 @@ class Catalog:
scanned=len(rows)
if base_only:rows=[x for x in rows if not is_derived_model(x)]
return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False),created_at=x.get('createdAt'),capabilities=capabilities(x)) for x in rows[:20]],'base_only':base_only,'scanned':scanned,'notice':notice}
- def files(self,repo):
+ def files(self,repo,revision=None):
repo=repo_id(repo)
+ if revision is not None and (not isinstance(revision,str) or not re.fullmatch("[a-f0-9]{40}",revision)):raise ValueError("Ungültige Quellversion.")
+ cache_key=(repo,revision)
with self.cache_lock:
- cached=self.cache.get(repo)
+ cached=self.cache.get(cache_key)
if cached and time.monotonic()-cached[0]<300:return cached[1]
- data=self._metadata('/api/models/'+repo+'?blobs=true')
+ data=self._metadata('/api/models/'+repo+('/revision/'+revision if revision else '')+'?blobs=true')
revision=data.get('sha','')
if not re.fullmatch('[a-f0-9]{40}',revision):raise ValueError('Keine feste Repository-Version verfügbar.')
files=[]
@@ -172,7 +176,7 @@ class Catalog:
result=dict(auth_configured=self.hub_auth.status()['configured'],capabilities=capabilities(data),repo=repo,revision=revision,files=files,gated=data.get('gated',False),license=(data.get('cardData') or {}).get('license'),url='https://huggingface.co/'+repo)
with self.cache_lock:
if len(self.cache)>=64:self.cache.pop(next(iter(self.cache)))
- self.cache[repo]=(time.monotonic(),result)
+ self.cache[cache_key]=(time.monotonic(),result)
return result
def status(self):
with self.lock:
@@ -188,8 +192,8 @@ class Catalog:
with self.lock:
if self.closing:raise ValueError('Deck wird beendet.')
if len(self.pending)>=20:raise ValueError('Warteschlange voll (20 Dateien).')
- with self.cache_lock:self.cache.pop(repo,None)
- data=self.files(repo)
+ with self.cache_lock:self.cache.pop((repo,revision),None)
+ data=self.files(repo,revision)
if data['gated'] and not self.hub_auth.token():raise ValueError('Dieses Repository benötigt eine Freigabe und einen Hugging-Face-Token unter Einstellungen → Zugang.')
if revision!=data['revision']:raise ValueError('Repository wurde geändert. Dateiliste neu laden.')
item=next((x for x in data['files'] if x['name']==filename),None)
diff --git a/execution_setup.py b/execution_setup.py
index ff38c0d..81bb291 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
+from profiles import image_recipe, video_recipe
from tts_runtime import REPO, REVISION
def assess(model, installed, profile=None):
@@ -22,6 +22,8 @@ def assess(model, installed, profile=None):
result.update(message='Für diese STT-Datei fehlt die Anbindung. Die geprüfte llama.cpp-Variante von Qwen3-ASR kann unter Einrichten zusätzlich geladen und ausdrücklich einem Profil zugeordnet werden.',alternative=dict(route='#stt-runtime',label='Unterstützte STT-Alternative einrichten (Modellwechsel)'))
elif kind=='audio':
result.update(message='Dieses TTS-Modell wird noch nicht ausgeführt. Deck unterstützt derzeit die festgelegte Qwen3-TTS-CustomVoice-Variante mit eingebauten Stimmen. MLX, Base mit Referenzstimme und andere TTS-Architekturen benötigen andere Anbindungen.',alternative=dict(route='#tts-runtime',label='Unterstützte TTS-Alternative ansehen (Modellwechsel)'))
+ if video_recipe(model):
+ result.update(state='runtime_missing',label='Komponentenrezept vorhanden · Video-Worker fehlt',components=[x['label'] for x in video_recipe(model).values()],message='Videoprofil anlegen und dort Komponenten öffnen: passende Dateien aus der Version des Hauptmodells nachladen und zuordnen. Die LTX-Video-Laufzeit ist noch nicht an Deck angebunden.')
if not result['runtime']:return result
result['installed']=bool(installed.get(kind))
if kind!='chat':
diff --git a/profiles-ui.js b/profiles-ui.js
index edcdb25..aea03c2 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=[],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'),kind==='chat'?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);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')}
${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.':'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('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;}});
@@ -20,17 +20,17 @@ window.ProfilesUI=(()=>{
el('profile-editor').innerHTML='
${profile.kind==='image'?'Bildlaufzeit → · ':'Videolaufzeit · '}${e(data.runtime)} · ${data.runtime_installed?'installiert':'noch nicht eingerichtet'}
`:''}`;
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;
- try{for(const r of missing){const f=r.candidates.find(f=>!f.gated||f.auth_configured);if(!f)throw Error('Keine frei herunterladbare Datei 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:'image'});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.';}
+ 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;}
};}
el('components-close').onclick=()=>el('profile-editor').replaceChildren();
el('components-refresh')?.addEventListener('click',()=>components(profile));
el('components-form')?.addEventListener('submit',async event=>{event.preventDefault();const button=el('components-save');button.disabled=true;try{await api('profiles/components',{id:profile.id,revision:profile.revision,components:Object.fromEntries(new FormData(event.target))});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Komponenten dem Profil zugeordnet. Kein Modell gestartet.');}catch(error){if(el('components-message'))el('components-message').textContent=error.message;}finally{button.disabled=false;}});
- root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:'image'});if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
+ root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
}catch(error){message(error.message);el('profile-editor').innerHTML='
Komponenten konnten nicht geladen werden. Öffne die Ansicht erneut.
';}
}
function gpuEditor(params){
diff --git a/profiles.py b/profiles.py
index 51bbdff..2d67a48 100644
--- a/profiles.py
+++ b/profiles.py
@@ -51,6 +51,20 @@ def image_recipe(model):
if model['repo']==FLUX_REPO and model['file']=='Flux.2 Klein-9B_fp16_nsfw.safetensors':return FLUX_COMPONENTS
return None
+LTX_REPO='Lightricks/LTX-2.5'
+LTX_FILE='diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors'
+LTX_COMPONENTS={
+ 'text_encoder':{'label':'Gemma 4 12B · Textencoder mit LTX-Projektionen','files':['text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors']},
+ 'video_vae':{'label':'Video-VAE · BF16','files':['vae/ltx-2.5-video-vae-bf16.safetensors']},
+ 'audio_vae':{'label':'Audio-VAE und Vocoder · BF16','files':['vae/ltx-2.5-audio-vae-bf16.safetensors']},
+ 'spatial_upsampler':{'label':'Spatial-Upsampler · zweistufige Distilled-Pipeline','files':['latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors']}
+}
+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
+
+def component_recipe(model):
+ return video_recipe(model) or image_recipe(model)
+
class Profiles:
def __init__(self,path,catalog):
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
@@ -73,6 +87,10 @@ class Profiles:
except ValueError as exc:p['model']=None;p['blockers']=[str(exc)]
if p.get('model') and image_recipe(p['model']) and self.image_runtime_ready():
p['blockers']=[b for b in p['blockers'] if not b.startswith('Eine eigene Bildlaufzeit')]
+ if p.get('model') and video_recipe(p['model']):
+ 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['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)
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p)
@@ -120,15 +138,16 @@ class Profiles:
return {'deleted':old['id']}
def _component(self,model,role,ident):
- recipe=image_recipe(model)
+ recipe=component_recipe(model)
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']!='image' or item['file'] not in recipe[role]['files']:raise ValueError('Die Datei gehört nicht zu dieser Komponente des Modells.')
+ 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.')
return item
def components(self,model_id):
model=self.catalog.entry(model_id)
- if not image_recipe(model) or model['kind']!='image':return dict(supported=False,requirements=[],message='Für dieses Modell ist noch kein Komponentenrezept hinterlegt. Keine automatische Zuordnung.')
- recipe=image_recipe(model);source=self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
+ 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=[]
for role,info in recipe.items():
available=[]
for item in entries:
@@ -137,14 +156,16 @@ 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 video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='LTX-Pipelines · Distilled BF16 · noch nicht an Deck angebunden',runtime_installed=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. Vollständige Dateien bedeuten noch keine ausführbare Video-Laufzeit oder 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.')
def assign(self,data):
- if set(data)!={'id','revision','components'} or not isinstance(data['components'],dict) or set(data['components'])-set(QWEN_COMPONENTS):raise ValueError('Ungültige Komponentenzuordnung.')
+ 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']!='image' 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') 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}
for role,ident in components.items():self._component(model,role,ident)
updated=dict(old,components=components,revision=old['revision']+1,updated_at=time.time())
diff --git a/test_video_components.py b/test_video_components.py
new file mode 100644
index 0000000..168d911
--- /dev/null
+++ b/test_video_components.py
@@ -0,0 +1,34 @@
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import Mock,patch
+from profiles import Profiles,LTX_REPO,LTX_FILE,LTX_COMPONENTS,video_recipe
+from catalog import Catalog,file_role
+from execution_setup import assess
+
+class VideoComponentsTests(unittest.TestCase):
+ def test_recipe_assignment_and_worker_block(self):
+ model=dict(id='main',repo=LTX_REPO,file=LTX_FILE,kind='video',revision='a'*40)
+ entries=[dict(id=role,repo=LTX_REPO,file=info['files'][0],kind='video',revision='a'*40,size=10) for role,info in LTX_COMPONENTS.items()]
+ lookup={x['id']:x for x in [model]+entries}
+ c=Mock();c.entry.side_effect=lambda ident:lookup[ident] if ident in lookup else (_ for _ in ()).throw(ValueError('missing'))
+ c.status.return_value={'entries':entries};c.files.return_value=dict(repo=LTX_REPO,revision='a'*40,files=[dict(name=x['file'],size=10) for x in entries],gated=True,auth_configured=True,license='ltx')
+ with tempfile.TemporaryDirectory() as d:
+ p=Profiles(Path(d)/'profiles.json',c);p.rows=[dict(id='p',revision=1,name='LTX',kind='video',model_id='main',parameters={})]
+ result=p.components('main');self.assertEqual(len(result['requirements']),4);c.files.assert_called_with(LTX_REPO,'a'*40)
+ self.assertTrue(all(len(r['available'])==1 for r in result['requirements']))
+ p.assign(dict(id='p',revision=1,components={x['id']:x['id'] for x in entries}))
+ self.assertFalse(p.status()['profiles'][0]['runnable']);self.assertEqual(len(p.status()['profiles'][0]['blockers']),1)
+ entries[0]['revision']='b'*40
+ with self.assertRaises(ValueError):p._component(model,'text_encoder','text_encoder')
+ with self.assertRaises(ValueError):p.assign(dict(id='p',revision=2,components={'unknown':''}))
+ self.assertEqual(assess(model,{})['state'],'runtime_missing')
+ def test_no_guesses_and_auxiliary_files_not_models(self):
+ self.assertIsNone(video_recipe(dict(kind='video',repo='other/repo',file=LTX_FILE)))
+ self.assertIsNone(video_recipe(dict(kind='video',repo=LTX_REPO,file=LTX_FILE.replace('bf16','nvfp4'))))
+ for info in LTX_COMPONENTS.values():self.assertFalse(file_role(info['files'][0])['profile_eligible'])
+ def test_metadata_uses_pinned_revision(self):
+ with tempfile.TemporaryDirectory() as d,patch('catalog.metadata',return_value={'sha':'a'*40,'siblings':[]}) as get:
+ c=Catalog(d);c.files(LTX_REPO,'a'*40)
+ self.assertIn('/revision/'+'a'*40,get.call_args.args[0])
+ with self.assertRaises(ValueError):c.files(LTX_REPO,'main')