diff --git a/ENDPOINT.md b/ENDPOINT.md index 8417392..e9c03d1 100644 --- a/ENDPOINT.md +++ b/ENDPOINT.md @@ -46,6 +46,7 @@ Alle Inferenzrouten benötigen `Authorization: Bearer `. | GET | `/health` | Listener-Health, ebenfalls authentifiziert | | POST | `/v1/chat/completions` | Textchat, JSON und SSE-Streaming, Sampling-Defaults aus dem Profil | | POST | `/v1/images/generations` | Qwen-Image-2.1-Rezept, n=1, `b64_json` | +| POST | `/v1/images/edits` | Multipart-Formular: `model=athena-image`, `prompt`, ein bis vier `image[]`; nur wenn das aktive Profil Referenzbilder unterstützt | | POST | `/v1/audio/speech` | Qwen3-TTS CustomVoice, WAV-Ausgabe | | POST | `/v1/audio/transcriptions` | Qwen3-ASR, Multipart-WAV → JSON-Transkript | @@ -55,9 +56,17 @@ verwenden die gespeicherte Auflösung, Schritte, Guidance und Seed; eine explizi eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs. Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings, -Vision-Eingaben, Bildbearbeitung, Video oder Musik-/Voice-Worker. TTS/STT benötigen keinen +allgemeine Bildbearbeitung für andere Modellfamilien, Video oder Musik-/Voice-Worker. Qwen Image 2.1 unterstützt Referenzbilder; TTS/STT benötigen keinen eigenen Port, sondern eigene Routen und Worker hinter demselben Listener. +`/v1/images/edits` akzeptiert PNG, JPEG und WebP mit maximal 10 MiB je Bild. Die +Profilfähigkeit begrenzt die Anzahl: Qwen Image 2.1 derzeit auf vier, andere +Bildrezepte auf null. Die Reihenfolge der wiederholten `image[]`-Felder bleibt +erhalten. Nach einem Profilwechsel gilt sofort dessen Fähigkeit; der feste Name +`athena-image` bleibt bestehen. Die Bildgröße stammt wie bei `/generations` aus +dem Profil. Hochgeladene Referenzen werden nur für den Auftrag im privaten +Jobverzeichnis abgelegt und danach entfernt. + Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides und Streaming-Optionen; unbekannte Erweiterungsfelder werden abgelehnt. Maximal 1 MiB JSON pro Anfrage, 16 MiB gepufferte Chatantwort. Keine Chunked-Uploads oder diff --git a/README.md b/README.md index eced015..d2d86fc 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose. Bildprofile mit vollständigem Qwen-Image-2.1-GGUF-Rezept lassen sich unter „Testen“ ausführen. Textprofile sind am eigenen [OpenAI-kompatiblen Endpunkt](ENDPOINT.md) ausführbar. Qwen3-TTS CustomVoice ist über eine eigene CUDA-Laufzeit ausführbar; Qwen3-ASR bietet Spracherkennung; Musik, Voice und Video bleiben vorbereitet. Sprachmodelle besitzen außerdem einen internen Testchat mit Abbruch und Speicherdiagnose. Details: [Modellverwaltung](STUDIO.md). +Der Bildtest und `/v1/images/edits` akzeptieren beim geprüften Qwen-Image-2.1-Rezept +bis zu vier Referenzbilder. Die Zahl wird pro aktivem Modellrezept angezeigt; +andere Bildmodelle erhalten keine ungeprüfte Edit-Fähigkeit. + ## Zugang und API-Token Beim ersten Öffnen werden Oberflächenkennwort und separater API-Token eingerichtet. diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 005de18..f0e8735 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -14,7 +14,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock -COPY api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py /app/ +COPY api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py /app/ COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ diff --git a/endpoint.py b/endpoint.py index 1cc78ce..9edf681 100644 --- a/endpoint.py +++ b/endpoint.py @@ -183,6 +183,15 @@ class APIHandler(BaseHTTPRequestHandler): if self.command!='POST':raise APIError('Route nicht gefunden.',404) if ep.video and ep.scheduler.gpu_mode!='llm' and self.path!='/v1/audio/transcriptions':raise APIError('Video-Modus aktiv; Chat-, Bild- und TTS-Aufträge sind gesperrt. Auf LLM zurückschalten.',503,'video_mode_active') if self.path=='/v1/audio/transcriptions':return self.transcription(ep) + if self.path=='/v1/images/edits': + from image_upload import read_image_upload + try: + fields,images=read_image_upload(self) + if not images:raise ValueError('Mindestens ein Referenzbild erforderlich.') + if set(fields)-{'model','prompt','size','n','response_format'}:raise ValueError('Nicht unterstützte Bildparameter.') + fields['n']=int(fields.get('n','1')) + return self.image(ep,fields,images) + except ValueError as exc:raise APIError(str(exc)) from None if self.path not in ('/v1/chat/completions','/v1/images/generations','/v1/audio/speech'):raise APIError('Route nicht implementiert.',404) if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.') try:length=int(self.headers.get('Content-Length','0')) @@ -286,18 +295,21 @@ class APIHandler(BaseHTTPRequestHandler): self.wfile.write(chunk);self.wfile.flush() raise InferenceError('Zeitlimit der Streaming-Antwort überschritten.') finally:conn.close() - def image(self,ep,data): + def image(self,ep,data,reference_images=None): if set(data)-{'model','prompt','n','size','response_format','user'}:raise APIError('Nicht unterstützte Bildparameter. Schritte/Guidance/Seed stehen im Profil.') if data.get('n',1)!=1 or data.get('response_format','b64_json')!='b64_json':raise APIError('Unterstützt werden n=1 und response_format=b64_json.') profile=ep.find_profile(data.get('model'),'image');params=profile['parameters'] + from profiles import image_capabilities + if len(reference_images or [])>image_capabilities(profile.get('model'))['reference_images']:raise APIError('Das aktive Bildprofil unterstützt diese Anzahl Referenzbilder nicht.') size=data.get('size') if size is not None and (not isinstance(size,str) or (size!='auto' and not re.fullmatch(r'[1-9][0-9]{1,4}x[1-9][0-9]{1,4}',size))):raise APIError('size muss auto oder eine Auflösung wie 1024x1024 sein.') # The selected profile owns resource limits; client size is only a preference. def allowed():return ep.allowed() and any(p['id']==profile['id'] and p['revision']==profile['revision'] and p['enabled'] for p in ep.rows()) with ep.scheduler.lease(('image',),allowed=allowed): - return self.generate_image(ep,profile,data) - def generate_image(self,ep,profile,data): - job=ep.images.start(profile['id'],data.get('prompt'),reserved=True) + return self.generate_image(ep,profile,data,reference_images or []) + def generate_image(self,ep,profile,data,reference_images=None): + options={'reference_images':reference_images} if reference_images else {} + job=ep.images.start(profile['id'],data.get('prompt'),reserved=True,**options) deadline=time.monotonic()+2100 while time.monotonic(){ const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;} - function html(readonly=false){return `

${readonly?'Bildlaufzeit':'Bildmodell testen'}

Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.

${readonly?'':`

Auflösung, Schritte, Guidance und Seed stammen aus dem Profil. Maximal 1024 × 1024 Pixel. Beide GPUs müssen frei sein; Deck stoppt keine produktiven Modelle. Der erste Start kann einige Minuten dauern.

`}

Textencoder: RTX 3060 · Diffusionsmodell und VAE: RTX 5080. CPU/RAM werden weiterhin zum Laden und Auslagern benötigt. GPU-Last entsteht nicht während aller Verarbeitungsschritte. Aktuelle Hardware-Messwerte →

`;} + function html(readonly=false){return `

${readonly?'Bildlaufzeit':'Bildmodell testen'}

Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.

${readonly?'':`

Auflösung, Schritte, Guidance und Seed stammen aus dem Profil. Maximal 1024 × 1024 Pixel. Beide GPUs müssen frei sein; Deck stoppt keine produktiven Modelle. Der erste Start kann einige Minuten dauern.

`}

Textencoder: RTX 3060 · Diffusionsmodell und VAE: RTX 5080. CPU/RAM werden weiterhin zum Laden und Auslagern benötigt. GPU-Last entsteht nicht während aller Verarbeitungsschritte. Aktuelle Hardware-Messwerte →

`;} function bind(readonly=false){ const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=false; const message=t=>{if(root.isConnected)el('image-test-message').textContent=t;}; - function selection(){if(readonly)return;const p=profiles.find(p=>p.id===el('image-test-profile').value);el('image-profile-info').textContent=p?`${p.parameters.width} × ${p.parameters.height} · ${p.parameters.steps} Schritte · Guidance ${p.parameters.guidance} · Seed ${p.parameters.seed}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'';el('image-generate').disabled=submitting||!p?.runnable||!status?.runtime_installed||status?.job?.state==='running';} + function selection(){if(readonly)return;const p=profiles.find(p=>p.id===el('image-test-profile').value),max=p?.capabilities?.reference_images||0;el('image-profile-info').textContent=p?`${p.parameters.width} × ${p.parameters.height} · ${p.parameters.steps} Schritte · Guidance ${p.parameters.guidance} · Seed ${p.parameters.seed}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'';el('image-reference-label').hidden=!max;el('image-reference-files').disabled=!max;el('image-reference-info').textContent=max?`Dieses Profil erlaubt bis zu ${max} Referenzbilder. Reihenfolge: wie ausgewählt; PNG/JPEG/WebP, je höchstens 10 MiB.`:'Dieses Profil unterstützt keine Referenzbilder.';el('image-generate').disabled=submitting||!p?.runnable||!status?.runtime_installed||status?.job?.state==='running';} async function refresh(){try{status=await api('image-tests');if(!root.isConnected)return;const j=status.job,signature=JSON.stringify(j);if(signature!==lastJob){lastJob=signature;el('image-test-state').innerHTML=j?`${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}

${e(j.profile_name)}

${e(j.phase)}

${e(j.gpu)}${j.encoder_gpu?' · Textencoder: '+e(j.encoder_gpu):''} · Seed ${e(j.seed)}

${j.state==='running'?'':''}`:`

${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}

Noch kein Bildtest gestartet.

`; el('image-test-result').innerHTML=j?.state==='complete'?`
Ergebnis des Bildtests
PNG herunterladen
`:''; el('image-cancel')?.addEventListener('click',async event=>{event.target.disabled=true;try{await api('image-tests/cancel',{});message('Abbruch angefordert; der eigene Bildworker wird beendet.');}catch(error){message(error.message);}}); }selection();}catch(error){message(error.message);}finally{if(root.isConnected)timer=setTimeout(refresh,2000);}} - if(!readonly){api('profiles').then(v=>{if(!root.isConnected)return;profiles=v.profiles.filter(p=>p.kind==='image');el('image-test-profile').innerHTML=profiles.map(p=>``).join('')||'';selection();}).catch(error=>message(error.message));el('image-test-profile').onchange=selection; - el('image-test-form').onsubmit=async event=>{event.preventDefault();submitting=true;selection();try{await api('image-tests/start',Object.fromEntries(new FormData(event.target)));message('Bildtest gestartet.');}catch(error){message(error.message);}finally{submitting=false;selection();}}; + if(!readonly){api('profiles').then(v=>{if(!root.isConnected)return;profiles=v.profiles.filter(p=>p.kind==='image');el('image-test-profile').innerHTML=profiles.map(p=>``).join('')||'';selection();}).catch(error=>message(error.message));el('image-test-profile').onchange=()=>{el('image-reference-files').value='';selection();}; + el('image-test-form').onsubmit=async event=>{event.preventDefault();submitting=true;selection();try{const form=new FormData(event.target),p=profiles.find(p=>p.id===el('image-test-profile').value),files=[...el('image-reference-files').files],max=p?.capabilities?.reference_images||0;if(files.length>max)throw Error(`Dieses Profil erlaubt höchstens ${max} Referenzbilder.`);if(files.some(f=>f.size>10*1024*1024))throw Error('Jedes Referenzbild darf höchstens 10 MiB groß sein.');form.delete('image[]');for(const file of files)form.append('image[]',file);const r=await fetch('/api/v1/image-tests/start',{method:'POST',headers:{'X-Athena-Deck':'1'},body:form});const result=await r.json();if(!r.ok)throw Error(result.error||'Bildtest fehlgeschlagen.');message('Bildtest gestartet.');}catch(error){message(error.message);}finally{submitting=false;selection();}}; } refresh(); } diff --git a/image_test.py b/image_test.py index 55149d1..1222f19 100644 --- a/image_test.py +++ b/image_test.py @@ -1,5 +1,7 @@ """Owned, serial ComfyUI test jobs. Never talks to the production image router.""" import json +import base64 +import binascii import os from pathlib import Path import secrets @@ -11,7 +13,7 @@ import threading import time import urllib.request import uuid -from profiles import QWEN_REPO, FLUX_REPO, image_recipe +from profiles import QWEN_REPO, FLUX_REPO, image_recipe, image_capabilities PYTHON=Path('/opt/deck-image-python/bin/python') COMFY=Path('/opt/deck-comfy') @@ -43,7 +45,8 @@ def select_gpus(devices,model_size,encoder_size,vae_size,offload=False): 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,family='qwen'): +def workflow(prompt,params,seed,family='qwen',references=()): + if references and family!='qwen':raise ValueError('Dieses Bildprofil unterstützt keine Referenzbilder.') if family=='flux': return { '1':{'class_type':'UNETLoader','inputs':{'unet_name':'model.safetensors','weight_dtype':'default'}}, @@ -61,7 +64,7 @@ def workflow(prompt,params,seed,family='qwen'): '13':{'class_type':'ConditioningZeroOut','inputs':{'conditioning':['4',0]}} } - return { + graph={ '1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}}, '2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors'}}, '3':{'class_type':'VAELoader','inputs':{'vae_name':'vae.safetensors'}}, @@ -71,6 +74,27 @@ def workflow(prompt,params,seed,family='qwen'): '7':{'class_type':'VAEDecode','inputs':{'samples':['6',0],'vae':['3',0]}}, '8':{'class_type':'SaveImage','inputs':{'filename_prefix':'result','images':['7',0]}} } + if references: + graph['4']['inputs']['vae']=['3',0] + for index,name in enumerate(references,1): + graph[str(20+index)]={'class_type':'LoadImage','inputs':{'image':name}} + graph['4']['inputs'][f'image_{index}']=[str(20+index),0] + graph['6']['inputs']['latent_image']=['4',2] + return graph + +def decode_references(values,limit): + if not isinstance(values,list) or len(values)>limit:raise ValueError(f'Dieses Bildprofil erlaubt höchstens {limit} Referenzbilder.') + result=[] + for value in values: + if not isinstance(value,str) or not value.startswith(('data:image/png;base64,','data:image/jpeg;base64,','data:image/webp;base64,')) or len(value)>14*1024*1024:raise ValueError('Referenzbild muss PNG, JPEG oder WebP bis 10 MiB sein.') + try:raw=base64.b64decode(value.split(',',1)[1],validate=True) + except (ValueError,binascii.Error):raise ValueError('Ungültige Bildkodierung.') from None + if not 01024 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')) @@ -127,16 +152,19 @@ class ImageTests: 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) 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) - self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release),daemon=True).start() + 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)) + self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release,references),daemon=True).start() return dict(self.job) - def _run(self,job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release=lambda:None): + def _run(self,job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release=lambda:None,references=()): directory=self.root/job_id;process=None try: 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')]: 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=[] + for index,(raw,extension) in enumerate(references,1): + name=f'reference-{index}.{extension}';(directory/'input'/name).write_bytes(raw);names.append(name) custom=directory/'custom_nodes'/'deck_encoder';custom.mkdir(parents=True) shutil.copyfile(Path(__file__).parent/'image_encoder_node.py',custom/'__init__.py') config={'deck_nodes':{'base_path':str(directory),'custom_nodes':'custom_nodes'},'deck':{'base_path':str(directory/'models'),'unet':'unet','clip':'clip','vae':'vae'}} @@ -168,7 +196,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') + graph=workflow(prompt,params,seed,'flux' if model['repo']==FLUX_REPO else 'qwen',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') @@ -203,6 +231,8 @@ class ImageTests: except ProcessLookupError:pass with self.lock: self.process=None;self.job.update(state=final_state,phase=phase,finished_at=time.time());self._save() + for name in (f'reference-{index}.{extension}' for index,(_,extension) in enumerate(references,1)): + (directory/'input'/name).unlink(missing_ok=True) finally:release() def image(self,job_id): with self.lock: diff --git a/image_upload.py b/image_upload.py new file mode 100644 index 0000000..9c3ae21 --- /dev/null +++ b/image_upload.py @@ -0,0 +1,34 @@ +"""Bounded multipart reader for image edit requests.""" +import base64 +from email import policy +from email.parser import BytesParser + +MAX_IMAGE=10*1024*1024 +MAX_BODY=4*MAX_IMAGE+65536 + +def read_image_upload(handler,profile_field='model'): + handler.connection.settimeout(45) + if handler.headers.get('Transfer-Encoding'):raise ValueError('Chunked Upload wird nicht unterstützt.') + try:length=int(handler.headers.get('Content-Length','0')) + except ValueError:raise ValueError('Ungültige Upload-Länge.') from None + content_type=handler.headers.get('Content-Type','') + if not 0=4 or not 04096:raise ValueError('Ungültiges oder doppeltes Formularfeld.') + try:fields[name]=data.decode('utf-8') + except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None + if profile_field not in fields:raise ValueError('Bildprofil fehlt.') + return fields,images diff --git a/profiles.py b/profiles.py index 5f8e988..88de30c 100644 --- a/profiles.py +++ b/profiles.py @@ -60,6 +60,10 @@ def image_recipe(model): 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={ @@ -88,6 +92,7 @@ class Profiles: 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.'] diff --git a/server.py b/server.py index 4cae2b9..bc9800a 100644 --- a/server.py +++ b/server.py @@ -446,6 +446,11 @@ class Handler(BaseHTTPRequestHandler): except OSError:return self.respond({'error':'Installation konnte nicht vorbereitet werden.'},503) if self.path in ('/api/v1/image-tests/start','/api/v1/image-tests/cancel'): try: + if self.path.endswith('/start') and self.headers.get('Content-Type','').startswith('multipart/form-data;'): + from image_upload import read_image_upload + fields,images=read_image_upload(self,'profile_id') + if set(fields)!={'profile_id','prompt'}:raise ValueError('Ungültige Bildtest-Anfrage.') + return self.respond(self.server.image_tests.start(fields['profile_id'],fields['prompt'],reference_images=images)) data=self.read_json() if self.path.endswith('/cancel') and not data:return self.respond(self.server.image_tests.stop()) if self.path.endswith('/start') and set(data)=={'profile_id','prompt'}:return self.respond(self.server.image_tests.start(**data)) diff --git a/test_image_test.py b/test_image_test.py index 04bfe4f..3b93e04 100644 --- a/test_image_test.py +++ b/test_image_test.py @@ -3,7 +3,7 @@ from pathlib import Path import tempfile import unittest from unittest.mock import patch,Mock -from image_test import ImageTests,workflow,select_gpus +from image_test import ImageTests,workflow,select_gpus,decode_references class ImageTestTests(unittest.TestCase): def test_workflow_uses_selected_profile_and_owned_aliases(self): @@ -15,6 +15,21 @@ 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_qwen_references_feed_conditioning_and_edit_latent(self): + p=dict(width=512,height=512,steps=4,guidance=1) + graph=workflow('synthetic edit',p,42,references=['reference-1.png','reference-2.jpg']) + self.assertEqual(graph['4']['inputs']['vae'],['3',0]) + self.assertEqual(graph['4']['inputs']['image_1'],['21',0]) + self.assertEqual(graph['4']['inputs']['image_2'],['22',0]) + self.assertEqual(graph['6']['inputs']['latent_image'],['4',2]) + self.assertEqual(graph['21']['inputs']['image'],'reference-1.png') + with self.assertRaises(ValueError):workflow('test',p,1,'flux',['reference-1.png']) + 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() + self.assertEqual(len(decode_references([png],4)),1) + for value,limit in [([png],0),([png]*5,4),(['data:image/png;base64,'+base64.b64encode(b'not png').decode()],4)]: + with self.assertRaises(ValueError):decode_references(value,limit) 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') diff --git a/test_image_upload.py b/test_image_upload.py new file mode 100644 index 0000000..af3f2dc --- /dev/null +++ b/test_image_upload.py @@ -0,0 +1,21 @@ +import io +import unittest +from types import SimpleNamespace +from image_upload import read_image_upload +from image_test import decode_references + +class UploadTests(unittest.TestCase): + def test_multiple_images_keep_order_and_reject_oversized_body(self): + boundary='synthetic-boundary' + parts=[] + for name,value,mime in [('model',b'athena-image','text/plain'),('prompt',b'change color','text/plain'),('image[]',b'\x89PNG\r\n\x1a\nfirst','image/png'),('image[]',b'\xff\xd8\xffsecond','image/jpeg')]: + parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\nContent-Type: {mime}\r\n\r\n'.encode()+value+b'\r\n') + body=b''.join(parts)+f'--{boundary}--\r\n'.encode() + handler=SimpleNamespace(connection=SimpleNamespace(settimeout=lambda _:None),headers={'Content-Type':'multipart/form-data; boundary='+boundary,'Content-Length':str(len(body))},rfile=io.BytesIO(body)) + fields,images=read_image_upload(handler) + self.assertEqual(fields,{'model':'athena-image','prompt':'change color'}) + self.assertEqual([raw for raw,_ in decode_references(images,4)],[b'\x89PNG\r\n\x1a\nfirst',b'\xff\xd8\xffsecond']) + handler.headers['Content-Length']=str(50*1024*1024) + with self.assertRaises(ValueError):read_image_upload(handler) + +if __name__=='__main__':unittest.main()