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')}

${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(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(' · ')}

${CatalogUI.executionHTML(p.execution)}
${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${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(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(' · ')}

${CatalogUI.executionHTML(p.execution)}
${p.runnable?'Laufzeit bereit · lädt bei Anfrage':'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${['image','video'].includes(kind)?``:''}
`).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.':'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='

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')}

Bildlaufzeit: ${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

`).join('')||'

Keine passende Quelldatei verfügbar.

'}
`).join('')}

Lade je eine passende Datei für Textencoder und VAE. 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 → · ':'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; - 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')