diff --git a/README.md b/README.md index 7f57dee..4f138a1 100644 --- a/README.md +++ b/README.md @@ -232,3 +232,5 @@ Installation, Verbindung und Einschränkungen: [LadyPoly](deploy/ladypoly/README Sprachmodell-GPUs und Vision-Projektor werden unabhängig zugeordnet. Der Modell-Split verteilt ausschließlich LLM-Gewichte; eine zusätzliche Projektor-GPU wird nur für mmproj sichtbar gemacht, nicht für Modell-Offload. Die Speicherprüfung berücksichtigt beide Geräte einschließlich Projektorbedarf. Referenzbilder: Alle derzeit ausführbaren Bildrezepte (Qwen Image 2.1 und FLUX.2 Klein 9B) erlauben bis zu vier Bilder pro Bearbeitungsauftrag. FLUX nutzt VAEEncode und verkettete ReferenceLatent-Nodes für beide Conditioning-Zweige nach dem offiziellen ComfyUI-Workflow: https://github.com/Comfy-Org/workflow_templates/blob/main/templates/image_flux2_klein_image_edit_9b_distilled.json . Neue Modellfamilien benötigen einen passenden Workflow; hochgeladene Referenzen werden niemals stillschweigend verworfen. + +Bildadapter werden zentral in `profiles.IMAGE_ADAPTERS` definiert: Modellfamilie, unterstütztes Gewichtsformat, Komponenten, Laufzeit, Workflow, Referenzbildlimit und Speicherpolitik. Bibliothek, Profile und Bildworker verwenden denselben Resolver `image_adapter`. Bekannte Quellen bleiben kompatibel; neue Varianten können über gespeicherte präzise Architektur-Metadaten oder die Hugging-Face-Basismodell-Zuordnung dieselbe Anbindung wiederverwenden. Ein Name mit „Qwen“ oder „Flux“ genügt nicht. Allgemeine FLUX-Architekturangaben unterscheiden Klein 9B nicht sicher von anderen FLUX-Varianten und schalten daher keinen Worker frei. Metadaten sind eine Zuordnungshilfe, keine Garantie für den Inhalt oder die Qualität veränderter Gewichte. Neue Architekturen und Dateiformate benötigen einen registrierten Adapter. Bereits heruntergeladene Varianten ohne ausreichende Metadaten bleiben ungeprüft; bestehende bekannte Profile bleiben erhalten. diff --git a/catalog.py b/catalog.py index 6a7599a..4f2bd48 100644 --- a/catalog.py +++ b/catalog.py @@ -89,9 +89,13 @@ def runtime_metadata(model): architecture=next((x for x in architectures if isinstance(x,str) and 0=0 else secrets.randbelow(2147483648) self.cancel.clear();self.job=dict(id=job_id,state='running',phase='Bildlaufzeit startet',profile_id=profile_id,profile_name=profile['name'],started_at=time.time(),gpu=gpu['name'],encoder_gpu=encoder_gpu['name'],seed=seed,parameters=params,reference_count=len(references)) @@ -181,7 +181,7 @@ class ImageTests: if self.cancel.is_set():raise InterruptedError() self._phase('Prompt-Aufwerter entladen; Bildlaufzeit startet') directory.mkdir(mode=0o700) - for role,item,filename in [('unet',model,'model.safetensors' if model['repo']==FLUX_REPO else 'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]: + for role,item,filename in [('unet',model,image_adapter(model)['model_filename']),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]: dest=directory/'models'/role;dest.mkdir(parents=True);(dest/filename).symlink_to(self.model_path(item)) for folder in ('output','temp','user','input'):(directory/folder).mkdir() names=[] @@ -218,7 +218,7 @@ class ImageTests: if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.') time.sleep(1) nodes=request('/object_info') - graph=workflow(prompt,params,seed,'flux' if model['repo']==FLUX_REPO else 'qwen',names) + graph=workflow(prompt,params,seed,image_adapter(model)['workflow'],names) required={node['class_type'] for node in graph.values()} if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Nodes für dieses Bildmodell.') self._phase('Auftrag wird verarbeitet · Textencoder auf RTX 3060 · Bildberechnung auf RTX 5080') diff --git a/profiles.py b/profiles.py index af1c329..7cefd02 100644 --- a/profiles.py +++ b/profiles.py @@ -59,14 +59,28 @@ FLUX_COMPONENTS={ 'text_encoder':{'label':'Textencoder für FLUX.2 Klein 9B','repo':FLUX_COMPONENT_REPO,'files':['split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors']}, 'vae':{'label':'VAE für FLUX.2','repo':FLUX_COMPONENT_REPO,'files':['split_files/vae/flux2-vae.safetensors']} } +# One registry owns runtime, workflow, format, components and resource policy. +IMAGE_ADAPTERS={ + 'qwen_image_21':dict(label='Qwen Image 2.1',workflow='qwen',model_filename='model.gguf',suffix='.gguf',runtime='ComfyUI + ComfyUI-GGUF',reference_images=4,offload=False,components={k:{'repo':QWEN_REPO,**v} for k,v in QWEN_COMPONENTS.items()},sources={QWEN_REPO.lower(),'qwen/qwen-image-2.1','comfy-org/qwen-image-2.1'},architectures={'qwenimage21transformer2dmodel','qwen_image_2_1'}), + 'flux2_klein_9b':dict(label='FLUX.2 Klein 9B',workflow='flux',model_filename='model.safetensors',suffix='.safetensors',runtime='ComfyUI',reference_images=4,offload=True,components=FLUX_COMPONENTS,sources={FLUX_REPO.lower(),'black-forest-labs/flux.2-klein-9b','comfy-org/flux2-klein-9b'},architectures={'flux2klein9btransformer2dmodel','flux2_klein_9b'}), +} +def image_adapter(model): + if not model:return None + filename=model.get('file','') + if not file_role(filename)['profile_eligible'] or '-of-' in filename:return None + meta=model.get('runtime_metadata') or {};architecture=str(meta.get('architecture') or '').lower() + lineage={str(x).lower() for x in meta.get('base_models',[]) if isinstance(x,str)} + repo=model.get('repo','').lower() + matches=[a for a in IMAGE_ADAPTERS.values() if filename.endswith(a['suffix']) and (repo in a['sources'] or architecture in a['architectures'] or bool(lineage & a['sources']))] + return matches[0] if len(matches)==1 else None + def image_recipe(model): - if model['repo']==QWEN_REPO and model['file'].endswith('.gguf'):return QWEN_COMPONENTS - if model['repo']==FLUX_REPO and model['file']=='Flux.2 Klein-9B_fp16_nsfw.safetensors':return FLUX_COMPONENTS - return None + adapter=image_adapter(model) + return adapter['components'] if adapter else None def image_capabilities(model): - """All currently supported image recipes have a reference-image workflow.""" - return {'reference_images':4 if model and image_recipe(model) else 0} + adapter=image_adapter(model) + return {'reference_images':adapter['reference_images'] if adapter else 0} LTX_REPO='Lightricks/LTX-2.5' LTX_FILE='diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors' @@ -126,9 +140,9 @@ class Profiles: p['model']=self.catalog.entry(p['model_id']) if p['kind']=='image':p['capabilities']=image_capabilities(p['model']) p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.'] - if p['kind']=='image' and (image_recipe(p['model']) or 'qwen-image-2.1' in p['model']['repo'].lower()): + if p['kind']=='image' and image_recipe(p['model']): p['blockers']=['Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.'] - for role,info in reversed(list((image_recipe(p['model']) or QWEN_COMPONENTS).items())): + for role,info in reversed(list(image_recipe(p['model']).items())): try:self._component(p['model'],role,p.get('components',{}).get(role)) except ValueError:p['blockers'].insert(0,info['label']+' fehlt oder ist noch nicht zugeordnet.') except ValueError as exc:p['model']=None;p['blockers']=[str(exc)] @@ -245,8 +259,8 @@ class Profiles: 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.') - 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.') + return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime=image_adapter(model)['runtime'],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 image_adapter(model)['workflow']=='flux' else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.') def manual_components(self,model,repo=None): source=self.catalog.files(repo or model['repo'],None if repo else model['revision']) entries=self.catalog.status()['entries'];requirements=[] diff --git a/test_image_test.py b/test_image_test.py index c391f23..f363839 100644 --- a/test_image_test.py +++ b/test_image_test.py @@ -40,6 +40,18 @@ class ImageTestTests(unittest.TestCase): self.assertEqual(image_capabilities(dict(repo=FLUX_REPO,file='Flux.2 Klein-9B_fp16_nsfw.safetensors'))['reference_images'],4) self.assertEqual(image_capabilities(dict(repo=QWEN_REPO,file='model.gguf'))['reference_images'],4) self.assertEqual(image_capabilities(dict(repo='unknown',file='model.safetensors'))['reference_images'],0) + def test_family_registry_reuses_workflow_from_lineage_not_filename(self): + from profiles import image_adapter,image_recipe,FLUX_COMPONENTS + m=dict(repo='someone/new-weights',file='custom.safetensors',runtime_metadata={'base_models':['black-forest-labs/FLUX.2-klein-9B']}) + self.assertEqual(image_adapter(m)['workflow'],'flux') + self.assertEqual(image_recipe(m),FLUX_COMPONENTS) + m['runtime_metadata']={};m['file']='flux-qwen-looking-name.safetensors' + self.assertIsNone(image_adapter(m)) + m['runtime_metadata']={'architecture':'Flux2Transformer2DModel'} + self.assertIsNone(image_adapter(m)) + m['runtime_metadata']={'architecture':'qwen_image_2_1'};m['file']='new.gguf' + self.assertEqual(image_adapter(m)['workflow'],'qwen') + m['file']='vae/new.gguf';self.assertIsNone(image_adapter(m)) def test_reference_validation_enforces_profile_limit_and_signature(self): import base64 png='data:image/png;base64,'+base64.b64encode(b'\x89PNG\r\n\x1a\nsynthetic').decode() diff --git a/test_model_management.py b/test_model_management.py index 0f599db..4d9ffe7 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -11,7 +11,7 @@ class ManagementTests(unittest.TestCase): def setUp(self): self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name);self.model_id='a'*64 target=self.root/'models'/self.model_id;target.mkdir(parents=True);(target/'model.gguf').write_bytes(b'GGUFtest') - self.entry=dict(repo='test/Qwen-Image-2.1',file='test.gguf',kind='image',size=8,revision='b'*40,sha256=hashlib.sha256(b'GGUFtest').hexdigest(),downloaded_at=1) + self.entry=dict(runtime_metadata={'architecture':'qwen_image_2_1'},repo='test/Qwen-Image-2.1',file='test.gguf',kind='image',size=8,revision='b'*40,sha256=hashlib.sha256(b'GGUFtest').hexdigest(),downloaded_at=1) (target/'entry.json').write_text(json.dumps(self.entry));self.catalog=Catalog(self.root/'models');self.profiles=Profiles(self.root/'profiles.json',self.catalog) def tearDown(self):self.tmp.cleanup() def request(self):return dict(id=None,revision=0,name='image-test',kind='image',model_id=self.model_id,parameters=dict(width=1024,height=1024,steps=25,seed=-1,guidance=1))