273 lines
24 KiB
Python
273 lines
24 KiB
Python
"""Server-persisted library-backed profiles with validated, typed parameters."""
|
||
import json
|
||
import re
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from catalog import file_role
|
||
|
||
SCHEMAS={
|
||
'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40),'mtp_tokens':(1,8,2),'mtp_min_p':(0,1,.05)},
|
||
'image':{'width':(256,2048,1024),'height':(256,2048,1024),'steps':(1,100,25),'seed':(-1,2147483647,-1),'guidance':(0,30,1)},
|
||
'audio':{'speed':(.25,4,1)},
|
||
'stt':{},'music':{},'voice':{},
|
||
'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)}
|
||
}
|
||
VIDEO_DEVICE_DEFAULTS={'video_device':'auto','text_encoder_device':'same'}
|
||
PROMPT_ENHANCER_REPOS={'t2i':'Qwen/Qwen-Image-2.1-PE-T2I','i2i':'Qwen/Qwen-Image-2.1-PE-I2I'}
|
||
PROMPT_ENHANCER_DEFAULTS={'t2i':None,'i2i':None,'device':'auto'}
|
||
def video_parameters(params):
|
||
params={**VIDEO_DEVICE_DEFAULTS,**params}
|
||
for key in VIDEO_DEVICE_DEFAULTS:
|
||
value=params[key]
|
||
if not isinstance(value,str) or not (value in (('auto',) if key=='video_device' else ('same',)) or re.fullmatch(r'GPU-[a-zA-Z0-9-]+',value)):
|
||
raise ValueError('Video-Gerät muss eine GPU-UUID sein; CPU wird im Disk-Streaming-Adapter noch nicht unterstützt.')
|
||
return params
|
||
|
||
CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu','cache_type_k':'q4_0','cache_type_v':'q4_0'}
|
||
CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05}
|
||
|
||
def chat_parameters(params):
|
||
params={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**params}
|
||
if params['gpu_reserve_mode'] not in ('auto','manual','none'):raise ValueError('Ungültiger GPU-Reservemodus.')
|
||
reserve=params['gpu_reserve_mib']
|
||
if not isinstance(reserve,dict) or any(k not in params['gpu_devices'] or type(v) is not int or not 0<=v<=32768 for k,v in reserve.items()):raise ValueError('GPU-Reserve muss je ausgewählter GPU zwischen 0 und 32768 MiB liegen.')
|
||
if params['gpu_reserve_mode']=='manual' and (not params['gpu_devices'] or set(reserve)!=set(params['gpu_devices'])):raise ValueError('Manuelle Reserve erfordert einen Wert für jede ausgewählte GPU.')
|
||
if params['gpu_offload'] not in ('auto','full'):raise ValueError('Ungültiger GPU-Auslagerungsmodus.')
|
||
if params['cache_type_k'] not in ('f16','q8_0','q4_0') or params['cache_type_v'] not in ('f16','q8_0','q4_0'):raise ValueError('KV-Cache: nur f16, q8_0 oder q4_0 unterstützt.')
|
||
if type(params['mtp']) is not bool:raise ValueError('MTP muss an oder aus sein.')
|
||
if params['vision_projector'] is not None and (not isinstance(params['vision_projector'],str) or not re.fullmatch(r'[a-f0-9]{64}',params['vision_projector'])):raise ValueError('Ungültige Projektor-ID.')
|
||
if params['vision_device']!='cpu' and (not isinstance(params['vision_device'],str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',params['vision_device'])):raise ValueError('Projektor-Gerät muss CPU oder eine gültige GPU-UUID sein.')
|
||
devices=params['gpu_devices'];split=params['tensor_split']
|
||
if not isinstance(devices,list) or len(devices)>16 or any(not isinstance(v,str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',v) for v in devices) or len(set(devices))!=len(devices):raise ValueError('GPUs müssen als eindeutige, geordnete GPU-UUIDs angegeben werden.')
|
||
if params['split_mode'] not in ('none','layer','row'):raise ValueError('Ungültiger GPU-Split-Modus.')
|
||
if not isinstance(split,list) or any(isinstance(v,bool) or not isinstance(v,(int,float)) or not 0<v<=100 for v in split):raise ValueError('GPU-Verteilung: positive Gewichte bis 100 verwenden.')
|
||
if split and (len(split)!=len(devices) or len(devices)<2 or params['split_mode']=='none'):raise ValueError('GPU-Verteilung benötigt mehrere ausgewählte GPUs und einen Split-Modus; je GPU einen Wert angeben.')
|
||
if len(devices)>1 and params['split_mode']=='none':raise ValueError('Bei mehreren GPUs Layer- oder Row-Split auswählen.')
|
||
return params
|
||
|
||
# Explicit model-card recipe. This is a source recommendation, not a runtime test.
|
||
QWEN_REPO='abenzerps/Qwen-Image-2.1-Uncensored-GGUF'
|
||
QWEN_COMPONENTS={
|
||
'text_encoder':{'label':'Textencoder','files':['text_encoders/qwen3vl_8b_int8_convrot.safetensors','text_encoders/qwen3vl_8b_bf16.safetensors']},
|
||
'vae':{'label':'VAE','files':['vae/qwen_image_2.1_vae_bf16.safetensors']}
|
||
}
|
||
FLUX_REPO='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'
|
||
FLUX_COMPONENT_REPO='Comfy-Org/flux2-klein-9B'
|
||
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']}
|
||
}
|
||
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
|
||
|
||
def image_capabilities(model):
|
||
"""Only advertise reference editing for the verified Qwen Image 2.1 workflow."""
|
||
return {'reference_images':4 if model and model.get('repo')==QWEN_REPO and model.get('file','').endswith('.gguf') else 0}
|
||
|
||
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
|
||
|
||
YUE2_REPO='audio-cpp/Yue2-3B-GGUF'
|
||
YUE2_COMPONENTS={
|
||
'vae':{'label':'YuE2-VAE · F16 empfohlen, F32 alternativ','files':['yue2-vae-f16.gguf','yue2-vae-f32.gguf']},
|
||
'model_config':{'label':'Modellkonfiguration','files':['sidecars/yue2-model-config.json']},
|
||
'generation_config':{'label':'Generierungskonfiguration','files':['sidecars/yue2-generation-config.json']},
|
||
'tokenizer':{'label':'Qwen-Tokenizer','files':['sidecars/yue2-qwen.tiktoken']},
|
||
'vae_config':{'label':'VAE-Konfiguration','files':['sidecars/yue2-vae-config.json']}
|
||
}
|
||
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
|
||
|
||
def component_recipe(model):
|
||
return 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)'}
|
||
|
||
def audio_package_recipe(model,source):
|
||
if not source or not model.get('repo','').startswith('audio-cpp/'):return None
|
||
for path in sorted(Path(source,'model_specs').glob('*.json')):
|
||
try:spec=json.loads(path.read_text())
|
||
except (ValueError,OSError):continue
|
||
for package in spec.get('packages',[]):
|
||
files=package.get('files',[])
|
||
if package.get('target_directory')!=model['repo'].split('/')[-1] or model['file'] not in files:continue
|
||
extras=[f for f in files if isinstance(f,str) and f!=model['file'] and not f.startswith('/') and '..' not in Path(f).parts]
|
||
return {f'runtime_file_{i}':dict(label='Laufzeit-Paket: '+f,files=[f]) for i,f in enumerate(extras)} or None
|
||
return None
|
||
|
||
class Profiles:
|
||
def _recipe(self,model):return component_recipe(model) or getattr(self,'runtime_recipe',lambda m:None)(model)
|
||
def __init__(self,path,catalog):
|
||
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
||
self.image_runtime_ready=lambda:False
|
||
self.prompt_enhancer_ready=lambda task:False
|
||
self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None
|
||
self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
||
def status(self):
|
||
with self.lock:
|
||
rows=json.loads(json.dumps(self.rows))
|
||
for p in rows:
|
||
if p['kind']=='image':p['prompt_enhancer']={**PROMPT_ENHANCER_DEFAULTS,**p.get('prompt_enhancer',{})}
|
||
if p['kind']=='video':p['parameters']={**VIDEO_DEVICE_DEFAULTS,**p['parameters']}
|
||
if p['kind']=='chat':p['parameters']={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**p['parameters']}
|
||
try:
|
||
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()):
|
||
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())):
|
||
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)]
|
||
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.get('model') and music_recipe(p['model']):
|
||
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['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']=='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)
|
||
if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.')
|
||
if p['kind']=='image':
|
||
for task,repo in PROMPT_ENHANCER_REPOS.items():
|
||
if p['prompt_enhancer'].get(task)==repo and not self.prompt_enhancer_ready(task):p['blockers'].append('Prompt-Aufwerter '+task.upper()+' fehlt. Unter Bildgenerierung → Aufwerter installieren.')
|
||
if any(k in MANUAL_ROLES for k in p.get('components',{})):p['blockers'].append('Manuelle Zusatzkomponenten sind ungeprüft und noch nicht an den Worker angebunden.')
|
||
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
|
||
return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}}
|
||
def save(self,data):
|
||
if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.')
|
||
kind=data['kind'];name=data['name'];params=data['parameters']
|
||
if kind=='video':raise ValueError('Videoprofile werden nicht mehr in Deck verwaltet. Originale LTX-Oberfläche verwenden.')
|
||
if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not (2<=len(name)<=64 and name[0].isalnum() and name[-1].isalnum() and re.fullmatch(r'[\w .-]+',name)):raise ValueError('API-Name: 2–64 Zeichen; Buchstaben, Ziffern, Leerzeichen, Punkt, Bindestrich oder Unterstrich. Anfang und Ende müssen Buchstabe oder Ziffer sein.')
|
||
model=self.catalog.entry(data['model_id'])
|
||
if model['repo']==QWEN_REPO and any(model['file'] in r['files'] for r in QWEN_COMPONENTS.values()):raise ValueError('Textencoder und VAE werden über Komponenten zugeordnet, nicht als Hauptmodell.')
|
||
if model['kind']!=kind or not model['profile_eligible']:raise ValueError('Eine Gewichtsdatei dieses Bereichs auswählen, keine Konfiguration.')
|
||
if kind=='chat' and isinstance(params,dict):
|
||
params=chat_parameters(params)
|
||
if params['vision_projector']:
|
||
projector=self.catalog.entry(params['vision_projector'])
|
||
if projector.get('role')!='vision_projector' or not projector['file'].endswith('.gguf'):raise ValueError('Eine heruntergeladene mmproj-GGUF-Datei auswählen.')
|
||
if kind=='video' and isinstance(params,dict):params=video_parameters(params)
|
||
if not isinstance(params,dict) or set(params)!=(set(SCHEMAS[kind]) | (set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(VIDEO_DEVICE_DEFAULTS) if kind=='video' else set())):raise ValueError('Unvollständige oder unbekannte Profilparameter.')
|
||
for key,(lo,hi,_) in SCHEMAS[kind].items():
|
||
value=params[key]
|
||
floating=key in ('guidance','speed','temperature','top_p','mtp_min_p')
|
||
if isinstance(value,bool) or not isinstance(value,(float,int) if floating else int) or not lo<=value<=hi:raise ValueError('Ungültiger Parameter: '+key)
|
||
if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Breite und Höhe müssen durch 64 teilbar sein.')
|
||
if kind=='chat' and params['ubatch']>params['batch']:raise ValueError('Microbatch darf nicht größer als Batch sein.')
|
||
with self.lock:
|
||
# Recheck under the profile lock so a concurrent library deletion cannot
|
||
# leave a newly saved profile pointing at a removed file.
|
||
self.catalog.entry(data['model_id'])
|
||
existing=next((p for p in self.rows if p['id']==data['id']),None)
|
||
if data['id'] is not None and not existing:raise ValueError('Profil nicht gefunden.')
|
||
if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.')
|
||
if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.')
|
||
components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {}
|
||
row=dict(components=components,prompt_enhancer=existing.get('prompt_enhancer',dict(PROMPT_ENHANCER_DEFAULTS)) if existing and existing['model_id']==model['id'] and kind=='image' else dict(PROMPT_ENHANCER_DEFAULTS) if kind=='image' else None,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time())
|
||
rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row]
|
||
self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
|
||
return row
|
||
|
||
def references(self,model_id):
|
||
with self.lock:
|
||
return [dict(id=p['id'],name=p['name'],kind=p['kind']) for p in self.rows
|
||
if model_id in (p.get('model_id'),p.get('parameters',{}).get('vision_projector'))
|
||
or model_id in (p.get('components') or {}).values()]
|
||
|
||
def configure_prompt_enhancer(self,data):
|
||
if not isinstance(data,dict) or set(data)!={'id','revision','prompt_enhancer'}:raise ValueError('Ungültige Prompt-Aufwerter-Einstellung.')
|
||
config=data['prompt_enhancer']
|
||
if not isinstance(config,dict) or set(config)!=set(PROMPT_ENHANCER_DEFAULTS):raise ValueError('Unvollständige Prompt-Aufwerter-Einstellung.')
|
||
if any(config[task] not in (None,repo) for task,repo in PROMPT_ENHANCER_REPOS.items()):raise ValueError('Für diese Aufgabe ist der Aufwerter nicht geprüft.')
|
||
if config['device'] not in ('auto','5080','3060','cpu'):raise ValueError('Ungültiges Gerät für den Aufwerter.')
|
||
with self.lock:
|
||
old=next((p for p in self.rows if p['id']==data['id'] and p['kind']=='image'),None)
|
||
if not old or data['revision']!=old['revision']:raise ValueError('Bildprofil geändert oder nicht gefunden. Ansicht neu laden.')
|
||
updated=dict(old,prompt_enhancer=dict(config),revision=old['revision']+1,updated_at=time.time())
|
||
rows=[updated if p is old else p for p in self.rows]
|
||
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
|
||
return updated
|
||
|
||
def delete(self,data):
|
||
if set(data)!={'id','revision'}:raise ValueError('Ungültige Profilfelder.')
|
||
with self.lock:
|
||
old=next((p for p in self.rows if p['id']==data['id']),None)
|
||
if not old or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
|
||
rows=[p for p in self.rows if p is not old]
|
||
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
|
||
return {'deleted':old['id']}
|
||
|
||
def _component(self,model,role,ident):
|
||
if role in MANUAL_ROLES:
|
||
item=self.catalog.entry(ident)
|
||
if item['kind']!=model['kind'] or item['id']==model['id']:raise ValueError('Andere Zusatzdatei aus derselben Kategorie wählen.')
|
||
return item
|
||
recipe=self._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']!=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.')
|
||
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=[]
|
||
for role,info in recipe.items():
|
||
available=[]
|
||
for item in entries:
|
||
try:
|
||
self._component(model,role,item['id']);available.append(item)
|
||
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 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.')
|
||
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=[]
|
||
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]
|
||
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'])]
|
||
requirements.append(dict(role=role,label=label,available=available,candidates=candidates,optional=True))
|
||
return dict(supported=True,manual=True,requirements=requirements,source=source['url'],license=source['license'],runtime='Siehe Laufzeiterkennung am Modell',runtime_installed=False,message='Kein vollständiges geprüftes Rezept für diese Auswahl. Diese Dateien sind Hinweise aus Dateinamen und keine bestätigten Pflichtkomponenten. Manuell ausgewählte Dateien werden als ungeprüft gespeichert und schalten keinen Worker frei.')
|
||
def assign(self,data):
|
||
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'] not in SCHEMAS 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(self._recipe(model) or {})|set(MANUAL_ROLES)):raise ValueError('Unbekannte Komponente für dieses Modell.')
|
||
components={k:v for k,v in {**old.get('components',{}),**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())
|
||
rows=[updated if p is old else p for p in self.rows]
|
||
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
|
||
return updated
|