Files
Athena-Deck/profiles.py
T

193 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Server-persisted library-backed profiles with validated, typed parameters."""
import json
import re
import threading
import time
import uuid
from pathlib import Path
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'}
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'}
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 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 params['vision_device'] not in params['gpu_devices']:raise ValueError('Projektor-GPU muss in der GPU-Auswahl enthalten 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
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()
self.image_runtime_ready=lambda: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']=='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['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']=='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.')
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 re.fullmatch('[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}',name):raise ValueError('API-Name: 2–64 Buchstaben, Ziffern, Bindestrich oder Unterstrich.')
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:
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,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 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):
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']!=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 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:
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 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 · Einstellungen → Laufzeiten → Video',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. Die Video-Laufzeit separat installieren und das Profil in der Übersicht auswählen. Vollständige Dateien sind keine 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):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 ('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())
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