diff --git a/STUDIO.md b/STUDIO.md index dbfa0a6..86854ba 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -207,3 +207,13 @@ Für einen Versuch ohne CPU-Offloading zusätzlich „Vollständig auf GPU · so Während eines Downloads können bis zu 20 weitere Dateien eingereiht werden, auch kategorienübergreifend. Genau ein Transfer läuft; nach Abschluss, Fehler oder manuellem Abbruch startet der nächste. Doppelte Dateien werden abgewiesen. Unter Downloads lassen sich wartende Einträge entfernen. Die Speicherprüfung berücksichtigt noch ausstehende Bytes aller geplanten Transfers plus 10 GiB Reserve. Die API liefert `queued`, `bytes_per_second` und `eta_seconds`. Geschwindigkeit basiert auf einem gleitenden Zeitfenster; ETA ist eine Schätzung und enthält keine abschließende Dateiverarbeitung. Vor ausreichenden Messwerten bleibt die Anzeige offen. Beim Serverneustart werden laufende und wartende Einträge als unterbrochen markiert; keine automatische Wiederaufnahme. + +### FLUX.2 Klein 9B einrichten und testen + +Unterstützt ist die Datei `Flux.2 Klein-9B_fp16_nsfw.safetensors` aus `RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569`. Die geprüfte Datei enthält BF16-Gewichte (16,9 GiB). Andere FLUX-Dateien werden nicht allein anhand ihres Namens automatisch freigegeben. + +Bildprofil anlegen → Komponenten → Alle fehlenden Dateien herunterladen → nach Abschluss Bibliothek aktualisieren → Zuordnung speichern → Testen. Die Komponenten stammen aus `Comfy-Org/flux2-klein-9B`: `split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors` und `split_files/vae/flux2-vae.safetensors`. Wenn genau eine passende Datei vorhanden ist, wird sie vorausgewählt. Downloads nutzen die bestehende Queue. Laufzeitinstallation bleibt unter Einstellungen / Laufzeiten / Bildgenerierung verfügbar. + +Neue FLUX-Profile starten mit 4 Schritten und Guidance 1. Bestehende Profile behalten ihre Werte. Pipeline: ComfyUI UNETLoader, Qwen3-FLUX2-Textencoder auf zweiter GPU, EmptyFlux2LatentImage, Flux2Scheduler und Euler, VAE. Der bestehende Low-VRAM-Modus lagert Teile des 16,9-GiB-Modells in den System-RAM aus; es wird nicht vollständige GPU-Belegung versprochen. Beide GPUs müssen frei sein; fremde Prozesse werden nicht gestoppt. Zunächst Text-zu-Bild bis 1024×1024; kein Image-to-Image-Editor für diese Integration. + +Verifikation auf Athena: eigene ungespeicherte Testprofilkopie, 512×512, 4 Schritte, Guidance 1, Seed 123, neutrales Fahrradmotiv. Vollständiger Job circa 43 Sekunden einschließlich Start und Entladen. PNG visuell geprüft. 150 automatisierte Tests erfolgreich. Gespeicherte Benutzerprofile und produktiver Router nicht verändert. diff --git a/image_encoder_node.py b/image_encoder_node.py index dd998e2..de6814b 100644 --- a/image_encoder_node.py +++ b/image_encoder_node.py @@ -6,15 +6,15 @@ import folder_paths class DeckTextEncoderLoader: @classmethod def INPUT_TYPES(cls): - return {'required':{'clip_name':(folder_paths.get_filename_list('text_encoders'),)}} + return {'required':{'clip_name':(folder_paths.get_filename_list('text_encoders'),)},'optional':{'family':(['qwen_image','flux2'],)}} RETURN_TYPES=('CLIP',) FUNCTION='load_clip' CATEGORY='Deck' - def load_clip(self,clip_name): + def load_clip(self,clip_name,family="qwen_image"): if torch.cuda.device_count()!=2:raise RuntimeError('Deck requires the two reserved GPUs.') device=torch.device('cuda:1') path=folder_paths.get_full_path_or_raise('text_encoders',clip_name) - clip=comfy.sd.load_clip(ckpt_paths=[path],embedding_directory=folder_paths.get_folder_paths('embeddings'),clip_type=comfy.sd.CLIPType.QWEN_IMAGE, + clip=comfy.sd.load_clip(ckpt_paths=[path],embedding_directory=folder_paths.get_folder_paths('embeddings'),clip_type=comfy.sd.CLIPType.FLUX2 if family=='flux2' else comfy.sd.CLIPType.QWEN_IMAGE, model_options={'load_device':device,'offload_device':torch.device('cpu'),'initial_device':torch.device('cpu')}) if clip.patcher.load_device!=device:raise RuntimeError('Textencoder could not be assigned to the reserved GPU.') return (clip,) diff --git a/image_test.py b/image_test.py index e91c05c..55149d1 100644 --- a/image_test.py +++ b/image_test.py @@ -11,7 +11,7 @@ import threading import time import urllib.request import uuid -from profiles import QWEN_REPO +from profiles import QWEN_REPO, FLUX_REPO, image_recipe PYTHON=Path('/opt/deck-image-python/bin/python') COMFY=Path('/opt/deck-comfy') @@ -35,15 +35,32 @@ def probe(): gpu,_=line.split(',',1);counts[gpu.strip()]=counts.get(gpu.strip(),0)+1 return [dict(uuid=u.strip(),name=n.strip(),total_mib=float(t),free_mib=float(f),processes=counts.get(u.strip(),0)) for u,n,t,f in (line.split(',') for line in rows.splitlines())] -def select_gpus(devices,model_size,encoder_size,vae_size): - needed=(model_size+vae_size+3*GIB)/1024**2 +def select_gpus(devices,model_size,encoder_size,vae_size,offload=False): + needed=(min(model_size,8*GIB)+vae_size+3*GIB if offload else model_size+vae_size+3*GIB)/1024**2 images=[g for g in devices if 'RTX 5080' in g['name'] and g['processes']==0 and g['free_mib']>needed] encoders=[g for g in devices if 'RTX 3060' in g['name'] and g['processes']==0 and g['free_mib']>(encoder_size+2*GIB)/1024**2] if not encoders:raise ValueError('Textencoder benötigt eine freie RTX 3060 mit ausreichend VRAM. Kein automatischer CPU-Fallback.') if not images:raise ValueError('Bildmodell benötigt eine freie RTX 5080 mit ausreichend VRAM. Deck stoppt keine anderen Modelle.') return max(images,key=lambda g:g['free_mib']),encoders[0] -def workflow(prompt,params,seed): +def workflow(prompt,params,seed,family='qwen'): + if family=='flux': + return { + '1':{'class_type':'UNETLoader','inputs':{'unet_name':'model.safetensors','weight_dtype':'default'}}, + '2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors','family':'flux2'}}, + '3':{'class_type':'VAELoader','inputs':{'vae_name':'vae.safetensors'}}, + '4':{'class_type':'CLIPTextEncode','inputs':{'clip':['2',0],'text':prompt}}, + '5':{'class_type':'EmptyFlux2LatentImage','inputs':{'width':params['width'],'height':params['height'],'batch_size':1}}, + '6':{'class_type':'SamplerCustomAdvanced','inputs':{'noise':['9',0],'guider':['10',0],'sampler':['11',0],'sigmas':['12',0],'latent_image':['5',0]}}, + '7':{'class_type':'VAEDecode','inputs':{'samples':['6',0],'vae':['3',0]}}, + '8':{'class_type':'SaveImage','inputs':{'filename_prefix':'result','images':['7',0]}}, + '9':{'class_type':'RandomNoise','inputs':{'noise_seed':seed}}, + '10':{'class_type':'CFGGuider','inputs':{'model':['1',0],'positive':['4',0],'negative':['13',0],'cfg':params['guidance']}}, + '11':{'class_type':'KSamplerSelect','inputs':{'sampler_name':'euler'}}, + '12':{'class_type':'Flux2Scheduler','inputs':{'steps':params['steps'],'width':params['width'],'height':params['height']}}, + '13':{'class_type':'ConditioningZeroOut','inputs':{'conditioning':['4',0]}} + } + return { '1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}}, '2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors'}}, @@ -97,17 +114,17 @@ class ImageTests: profile=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id),None) if not profile or profile['kind']!='image' or not profile['model']:raise ValueError('Bildprofil nicht verfügbar.') model=profile['model'] - if model['repo']!=QWEN_REPO or not model['file'].endswith('.gguf'):raise ValueError('Der Bildtest unterstützt zunächst Qwen-Image-2.1 GGUF aus dem hinterlegten Rezept.') + if not image_recipe(model):raise ValueError('Für dieses Bildmodell fehlt ein Komponentenrezept.') params=profile['parameters'] if params['width']>1024 or params['height']>1024:raise ValueError('Der isolierte Test unterstützt maximal 1024 × 1024 Pixel.') 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) + required_ram=max(20*GIB,encoder['size']*2+4*GIB,model['size']+8*GIB if model['repo']==FLUX_REPO else 0) headroom=cgroup_headroom() if headroom is not None and headroom=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) @@ -117,7 +134,7 @@ class ImageTests: directory=self.root/job_id;process=None try: directory.mkdir(mode=0o700) - for role,item,filename in [('unet',model,'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]: + 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')]: 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() custom=directory/'custom_nodes'/'deck_encoder';custom.mkdir(parents=True) @@ -151,10 +168,11 @@ class ImageTests: if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.') time.sleep(1) nodes=request('/object_info') - required={'UnetLoaderGGUF','DeckTextEncoderLoader','VAELoader','TextEncodeQwenImage21','EmptyLatentImage','KSampler','VAEDecode','SaveImage'} - if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Qwen/GGUF-Nodes.') + graph=workflow(prompt,params,seed,'flux' if model['repo']==FLUX_REPO else 'qwen') + 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') - response=request('/prompt',{'prompt':workflow(prompt,params,seed),'client_id':'deck-'+job_id});prompt_id=response['prompt_id'] + response=request('/prompt',{'prompt':graph,'client_id':'deck-'+job_id});prompt_id=response['prompt_id'] deadline=time.monotonic()+1800 while time.monotonic(){ 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')}

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.

`:''}

`; + 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);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.';} + 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;}}); @@ -87,6 +93,7 @@ window.ProfilesUI=(()=>{ function editor(p=null,id=null){ panelSequence++; if(!models.length)return;const data=p||{id:null,revision:0,name:'',model_id:id||models[0].id,parameters:Object.fromEntries(Object.entries(schema).map(([k,v])=>[k,v[2]]))}; + if(!p&&kind==='image'&&models.find(m=>m.id===data.model_id)?.repo==='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569'){data.parameters.steps=4;data.parameters.guidance=1;} el('profile-editor').innerHTML=`

${data.id?'Profil bearbeiten':'Profil anlegen'}

${Object.entries(schema).map(([k,[min,max,defaultValue]])=>``).join('')}
${kind==='chat'?gpuEditor(data.parameters)+`

Vision-Projektor

Passende mmproj.gguf unter Entdecken herunterladen – auch aus einem anderen Repository als das quantisierte Modell. Modell und Projektor müssen zusammenpassen; der Dateiname allein garantiert keine Kompatibilität. GPU-Ziel muss oben ausgewählt sein. GPU-Projektoren erhalten eine zusätzliche konservative Speicherreserve.

Nur für GGUF-Modelle mit MTP-Gewichten und kompatible Builds. Zusätzlicher Speicherbedarf; Geschwindigkeit hängt vom Modell ab. Referenz Medium: 2 Draft-Token, Mindestwahrscheinlichkeit 0,05, Draft-KV f16.

`:''}

Speichern startet kein Modell. Die Ausführung benötigt eine passende Laufzeit und gegebenenfalls zusätzliche Modelldateien. ${kind==='chat'?'Das Kontextbudget gilt insgesamt für alle Slots.':''}

`; if(kind==='chat'){const threads=el('profile-form').querySelector('[name=threads]').closest('label');const advanced=document.createElement('details');advanced.innerHTML='Erweitert: CPU-Threads

CPU-Threads steuern CPU-Arbeit und Offloading, nicht die Anzahl der CUDA-Rechenkerne.

';advanced.append(threads);el('profile-form').insertBefore(advanced,el('profile-form').querySelector('.note'));} if(kind==='chat'){bindProjectorDiscovery();const f=el('profile-form');const reserveFields=()=>{for(const n of ['reserve_first','reserve_second'])f.elements[n].disabled=f.elements.gpu_reserve_mode.value!=='manual';};f.elements.gpu_reserve_mode.onchange=reserveFields;reserveFields();} diff --git a/profiles.py b/profiles.py index 9dea0fe..e578d8d 100644 --- a/profiles.py +++ b/profiles.py @@ -39,6 +39,17 @@ 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 + class Profiles: def __init__(self,path,catalog): self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() @@ -53,13 +64,13 @@ class Profiles: try: p['model']=self.catalog.entry(p['model_id']) p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.'] - if p['kind']=='image' and 'qwen-image-2.1' in p['model']['repo'].lower(): + 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(QWEN_COMPONENTS.items())): + 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 p['model']['repo']==QWEN_REPO and p['model']['file'].endswith('.gguf') and self.image_runtime_ready(): + 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['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.') @@ -106,24 +117,25 @@ class Profiles: return {'deleted':old['id']} def _component(self,model,role,ident): - if model['repo']!=QWEN_REPO or role not in QWEN_COMPONENTS:raise ValueError('Für dieses Modell ist kein geprüftes Komponentenrezept hinterlegt.') + recipe=image_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']!=QWEN_REPO or item['kind']!='image' or item['file'] not in QWEN_COMPONENTS[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']!='image' or item['file'] not in recipe[role]['files']:raise ValueError('Die Datei gehört nicht zu dieser Komponente des Modells.') return item def components(self,model_id): model=self.catalog.entry(model_id) - if model['repo']!=QWEN_REPO or model['kind']!='image':return dict(supported=False,requirements=[],message='Für dieses Modell ist noch kein Komponentenrezept hinterlegt. Keine automatische Zuordnung.') - source=self.catalog.files(QWEN_REPO);entries=self.catalog.status()['entries'];requirements=[] - for role,info in QWEN_COMPONENTS.items(): + 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=[] + 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=QWEN_REPO,revision=source['revision'],gated=source['gated']) for name in info['files'] for f in source['files'] if f['name']==name] + candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated']) 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)) return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(), - message='Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.') + 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.') with self.lock: diff --git a/studio.js b/studio.js index 1fb8651..d3a365c 100644 --- a/studio.js +++ b/studio.js @@ -21,7 +21,7 @@ const Studio=(()=>{ document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);}); } function runtimeOverview(){return `
EINSTELLUNGEN

Laufzeiten

Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.

llama.cpp

Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.

llama.cpp öffnen →

Bildgenerierung

Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.

Bildlaufzeit öffnen →

Docker

Optionale Container-Umgebung für zusätzliche Oberflächen und Studios.

Docker öffnen →
`;} - function imageRuntime(){return `
EINSTELLUNGEN / LAUFZEITEN

Bildgenerierung

ComfyUI + ComfyUI-GGUF

Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.

Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.

Installationsstatus wird geprüft …

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} + function imageRuntime(){return `
EINSTELLUNGEN / LAUFZEITEN

Bildgenerierung

ComfyUI + ComfyUI-GGUF

Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF und das unterstützte FLUX.2-Klein-9B-Modell. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.

Textencoder läuft auf der RTX 3060, Diffusionsmodell und VAE auf der RTX 5080. Bei großen FLUX-Gewichten wird zusätzlich System-RAM genutzt. Beide GPUs müssen frei sein. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.

Installationsstatus wird geprüft …

Bildgenerierung → Testen öffnen
${ImageTestUI.html(true)}`;} function openProfile(page,modelId){render(page,true,modelId);} return {render,openProfile}; })(); diff --git a/test_image_test.py b/test_image_test.py index d17db49..04bfe4f 100644 --- a/test_image_test.py +++ b/test_image_test.py @@ -15,6 +15,15 @@ class ImageTestTests(unittest.TestCase): self.assertEqual(graph['6']['inputs']['steps'],12) self.assertEqual(graph['6']['inputs']['cfg'],1.5) self.assertEqual(graph['5']['inputs']['height'],768) + def test_flux_graph_and_offload(self): + p=dict(width=512,height=512,steps=4,guidance=1) + graph=workflow('a red bicycle',p,42,'flux') + self.assertEqual(graph['1']['class_type'],'UNETLoader');self.assertEqual(graph['2']['inputs']['family'],'flux2') + self.assertEqual(graph['12']['class_type'],'Flux2Scheduler');self.assertEqual(graph['12']['inputs']['steps'],4) + self.assertEqual(graph['5']['class_type'],'EmptyFlux2LatentImage') + devices=[dict(name='RTX 5080',uuid='a',free_mib=16000,processes=0),dict(name='RTX 3060',uuid='b',free_mib=12000,processes=0)] + self.assertEqual(select_gpus(devices,18*1024**3,8*1024**3,400*1024**2,offload=True)[0]['uuid'],'a') + with self.assertRaises(ValueError):select_gpus(devices,18*1024**3,8*1024**3,400*1024**2) def test_two_gpu_selection_rejects_busy_or_small_encoder_gpu(self): devices=[dict(name='NVIDIA GeForce RTX 3060',uuid='encoder',processes=0,free_mib=11962),dict(name='NVIDIA GeForce RTX 5080',uuid='image',processes=0,free_mib=15923)] image,encoder=select_gpus(devices,7*1024**3,9*1024**3,700*1024**2) diff --git a/test_model_management.py b/test_model_management.py index 0112281..fb95382 100644 --- a/test_model_management.py +++ b/test_model_management.py @@ -15,6 +15,19 @@ class ManagementTests(unittest.TestCase): (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)) + def test_flux_recipe_requires_matching_components(self): + from profiles import FLUX_REPO,FLUX_COMPONENTS + from unittest.mock import patch + path=self.root/'models'/self.model_id + self.entry.update(repo=FLUX_REPO,file='Flux.2 Klein-9B_fp16_nsfw.safetensors');(path/'entry.json').write_text(json.dumps(self.entry));(path/'model.safetensors').write_bytes(b'GGUFtest') + saved=self.profiles.save(self.request());ids={};files=[] + for n,(role,info) in enumerate(FLUX_COMPONENTS.items(),1): + ident=str(n)*64;ids[role]=ident;target=self.root/'models'/ident;target.mkdir();(target/'model.safetensors').write_bytes(b'component');(target/'entry.json').write_text(json.dumps(dict(repo=info['repo'],file=info['files'][0],kind='image',size=9,revision='c'*40)));files.append(dict(name=info['files'][0],size=9)) + with patch.object(self.catalog,'files',return_value=dict(files=files,revision='c'*40,gated=False,url='https://huggingface.co/example',license='test')): + self.assertTrue(self.profiles.components(self.model_id)['supported']) + self.profiles.assign(dict(id=saved['id'],revision=1,components=ids));self.profiles.image_runtime_ready=lambda:True + self.assertTrue(self.profiles.status()['profiles'][0]['runnable']) + with self.assertRaises(ValueError):self.profiles._component(self.catalog.entry(self.model_id),'vae',ids['text_encoder']) def test_chat_medium_and_legacy_parameters(self): self.entry['kind']='chat';(self.root/'models'/self.model_id/'entry.json').write_text(json.dumps(self.entry)) params=dict(context=160000,slots=2,threads=6,batch=2048,ubatch=256)