Centralize image family runtime and workflow adapters
This commit is contained in:
@@ -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.
|
||||
|
||||
+5
-1
@@ -89,9 +89,13 @@ def runtime_metadata(model):
|
||||
architecture=next((x for x in architectures if isinstance(x,str) and 0<len(x)<=100),None)
|
||||
architecture=architecture or next((config.get(k) for k in ('_class_name','model_type') if isinstance(config.get(k),str) and 0<len(config[k])<=100),None)
|
||||
tags=[x for x in model.get('tags',[]) if isinstance(x,str) and len(x)<=80][:32] if isinstance(model.get('tags'),list) else []
|
||||
card=model.get('cardData') if isinstance(model.get('cardData'),dict) else {}
|
||||
base=card.get('base_model',[])
|
||||
base=[base] if isinstance(base,str) else base if isinstance(base,list) else []
|
||||
base_models=[x for x in base if isinstance(x,str) and re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+',x)][:8]
|
||||
return dict(pipeline_tag=model.get('pipeline_tag') if isinstance(model.get('pipeline_tag'),str) and len(model['pipeline_tag'])<=80 else None,
|
||||
library_name=model.get('library_name') if isinstance(model.get('library_name'),str) and len(model['library_name'])<=80 else None,
|
||||
architecture=architecture,tags=tags)
|
||||
architecture=architecture,tags=tags,base_models=base_models)
|
||||
|
||||
class Catalog:
|
||||
def __init__(self,root):
|
||||
|
||||
+3
-3
@@ -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, video_recipe
|
||||
from profiles import image_recipe, image_adapter, video_recipe
|
||||
from tts_runtime import REPO, REVISION
|
||||
|
||||
def _assess(model, installed, profile=None):
|
||||
@@ -16,8 +16,8 @@ def _assess(model, installed, profile=None):
|
||||
result['family']='Qwen3-ASR 0.6B'
|
||||
result.update(runtime='llama.cpp · Qwen3-ASR auf CPU',route='#stt-runtime',components=['Passender Audio-Projektor (unter Einrichten herunterladen)'])
|
||||
elif kind=='image' and image_recipe(model):
|
||||
result['family']='FLUX.2 Klein 9B' if 'FLUX' in filename.upper() else 'Qwen Image 2.1'
|
||||
result.update(runtime='ComfyUI + ComfyUI-GGUF',route='#image-runtime',components=[x['label'] for x in image_recipe(model).values()])
|
||||
result['family']=image_adapter(model)['label']
|
||||
result.update(runtime=image_adapter(model)['runtime'],route='#image-runtime',components=[x['label'] for x in image_recipe(model).values()])
|
||||
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)'])
|
||||
|
||||
+5
-5
@@ -13,7 +13,7 @@ import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
from profiles import QWEN_REPO, FLUX_REPO, image_recipe, image_capabilities
|
||||
from profiles import image_adapter, image_recipe, image_capabilities
|
||||
|
||||
PYTHON=Path('/opt/deck-image-python/bin/python')
|
||||
COMFY=Path('/opt/deck-comfy')
|
||||
@@ -160,11 +160,11 @@ class ImageTests:
|
||||
encoder=self.profiles._component(model,'text_encoder',profile.get('components',{}).get('text_encoder'))
|
||||
vae=self.profiles._component(model,'vae',profile.get('components',{}).get('vae'))
|
||||
mem={line.split(':')[0]:int(line.split()[1])*1024 for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith(('MemAvailable:','MemTotal:'))}
|
||||
required_ram=max(20*GIB,encoder['size']*2+4*GIB,model['size']+8*GIB if model['repo']==FLUX_REPO else 0)
|
||||
required_ram=max(20*GIB,encoder['size']*2+4*GIB,model['size']+8*GIB if image_adapter(model)['offload'] else 0)
|
||||
headroom=cgroup_headroom()
|
||||
if headroom is not None and headroom<required_ram:raise ValueError('Deck-RAM-Limit reicht für Textencoder und Arbeitsdaten nicht aus.')
|
||||
if mem.get('MemAvailable',0)<required_ram+4*GIB:raise ValueError('Aktuell zu wenig freier System-RAM; produktive Dienste bleiben unverändert.')
|
||||
gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size'],offload=model['repo']==FLUX_REPO);self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
gpu,encoder_gpu=select_gpus(probe(),model['size'],encoder['size'],vae['size'],offload=image_adapter(model)['offload']);self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
if shutil.disk_usage(self.root).free<10*GIB:raise ValueError('Weniger als 10 GiB freier Plattenspeicher.')
|
||||
job_id=uuid.uuid4().hex;seed=params['seed'] if params['seed']>=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')
|
||||
|
||||
+23
-9
@@ -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=[]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user