Add profile-aware image reference editing

This commit is contained in:
Mikei386
2026-09-29 20:17:20 +02:00
parent 67cf57a5b7
commit f1ccb02e10
11 changed files with 156 additions and 21 deletions
+10 -1
View File
@@ -46,6 +46,7 @@ Alle Inferenzrouten benötigen `Authorization: Bearer <Deck-API-Token>`.
| GET | `/health` | Listener-Health, ebenfalls authentifiziert | | GET | `/health` | Listener-Health, ebenfalls authentifiziert |
| POST | `/v1/chat/completions` | Textchat, JSON und SSE-Streaming, Sampling-Defaults aus dem Profil | | 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/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/speech` | Qwen3-TTS CustomVoice, WAV-Ausgabe |
| POST | `/v1/audio/transcriptions` | Qwen3-ASR, Multipart-WAV → JSON-Transkript | | 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 eigenen Jobverzeichnis. Kein Bildprompt wird persistiert; PNG-Metadaten sind
deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs. deaktiviert. n=1; Ausgabe zunächst als Base64, keine öffentlich abrufbaren Bild-URLs.
Die API ist bewusst ein Teilumfang: noch keine `/v1/responses`, Embeddings, 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. 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 Chat akzeptiert übliche Nachrichten, Tools, response_format, Sampling-Overrides
und Streaming-Optionen; unbekannte Erweiterungsfelder werden abgelehnt. Maximal und Streaming-Optionen; unbekannte Erweiterungsfelder werden abgelehnt. Maximal
1 MiB JSON pro Anfrage, 16 MiB gepufferte Chatantwort. Keine Chunked-Uploads oder 1 MiB JSON pro Anfrage, 16 MiB gepufferte Chatantwort. Keine Chunked-Uploads oder
+4
View File
@@ -19,6 +19,10 @@ abgeschlossener Einträge ohne Dateiverlust. Entdecken zeigt Größen und eine
konservative Gewichts-Speicherprüfung; noch keine vollständige Laufzeitprognose. 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). 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 ## Zugang und API-Token
Beim ersten Öffnen werden Oberflächenkennwort und separater API-Token eingerichtet. Beim ersten Öffnen werden Oberflächenkennwort und separater API-Token eingerichtet.
+1 -1
View File
@@ -14,7 +14,7 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
WORKDIR /app WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-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 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/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
+16 -4
View File
@@ -183,6 +183,15 @@ class APIHandler(BaseHTTPRequestHandler):
if self.command!='POST':raise APIError('Route nicht gefunden.',404) 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 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/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.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.') if self.headers.get('Transfer-Encoding'):raise APIError('Chunked Upload wird nicht unterstützt.')
try:length=int(self.headers.get('Content-Length','0')) try:length=int(self.headers.get('Content-Length','0'))
@@ -286,18 +295,21 @@ class APIHandler(BaseHTTPRequestHandler):
self.wfile.write(chunk);self.wfile.flush() self.wfile.write(chunk);self.wfile.flush()
raise InferenceError('Zeitlimit der Streaming-Antwort überschritten.') raise InferenceError('Zeitlimit der Streaming-Antwort überschritten.')
finally:conn.close() 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 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.') 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'] 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') 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.') 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. # 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()) 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): with ep.scheduler.lease(('image',),allowed=allowed):
return self.generate_image(ep,profile,data) return self.generate_image(ep,profile,data,reference_images or [])
def generate_image(self,ep,profile,data): def generate_image(self,ep,profile,data,reference_images=None):
job=ep.images.start(profile['id'],data.get('prompt'),reserved=True) 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 deadline=time.monotonic()+2100
while time.monotonic()<deadline: while time.monotonic()<deadline:
current=ep.images.status()['job'] current=ep.images.status()['job']
+4 -4
View File
@@ -1,17 +1,17 @@
window.ImageTestUI=(()=>{ window.ImageTestUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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;} 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 `<section id="image-test"><div class="section-heading"><div><h2>${readonly?'Bildlaufzeit':'Bildmodell testen'}</h2><p>Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.</p></div></div>${readonly?'':`<form id="image-test-form" class="card"><label>Bildprofil<select name="profile_id" id="image-test-profile" required><option value="">Profile werden geladen …</option></select></label><p id="image-profile-info"></p><label>Prompt<textarea name="prompt" maxlength="4000" rows="5" required placeholder="Beschreibe das gewünschte Bild …"></textarea></label><button id="image-generate" disabled>Bild generieren</button><p class="small">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.</p></form>`}<p class="note">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. <a class="link" href="#hardware">Aktuelle Hardware-Messwerte →</a></p><p id="image-test-message" role="status"></p><section id="image-test-state" class="card"></section><section id="image-test-result"></section></section>`;} function html(readonly=false){return `<section id="image-test"><div class="section-heading"><div><h2>${readonly?'Bildlaufzeit':'Bildmodell testen'}</h2><p>Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.</p></div></div>${readonly?'':`<form id="image-test-form" class="card"><label>Bildprofil<select name="profile_id" id="image-test-profile" required><option value="">Profile werden geladen …</option></select></label><p id="image-profile-info"></p><label>Prompt<textarea name="prompt" maxlength="4000" rows="5" required placeholder="Beschreibe das gewünschte Bild …"></textarea></label><label id="image-reference-label">Referenzbilder (optional)<input id="image-reference-files" name="image[]" type="file" accept="image/png,image/jpeg,image/webp" multiple></label><p id="image-reference-info" class="small"></p><button id="image-generate" disabled>Bild generieren</button><p class="small">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.</p></form>`}<p class="note">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. <a class="link" href="#hardware">Aktuelle Hardware-Messwerte →</a></p><p id="image-test-message" role="status"></p><section id="image-test-state" class="card"></section><section id="image-test-result"></section></section>`;}
function bind(readonly=false){ function bind(readonly=false){
const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=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;}; 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?`<span class="pill">${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}</span><h3>${e(j.profile_name)}</h3><p>${e(j.phase)}</p><p class="small">${e(j.gpu)}${j.encoder_gpu?' · Textencoder: '+e(j.encoder_gpu):''} · Seed ${e(j.seed)}</p>${j.state==='running'?'<button class="secondary" id="image-cancel">Bildtest abbrechen</button>':''}`:`<h3>${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}</h3><p>Noch kein Bildtest gestartet.</p>`; 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?`<span class="pill">${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}</span><h3>${e(j.profile_name)}</h3><p>${e(j.phase)}</p><p class="small">${e(j.gpu)}${j.encoder_gpu?' · Textencoder: '+e(j.encoder_gpu):''} · Seed ${e(j.seed)}</p>${j.state==='running'?'<button class="secondary" id="image-cancel">Bildtest abbrechen</button>':''}`:`<h3>${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}</h3><p>Noch kein Bildtest gestartet.</p>`;
el('image-test-result').innerHTML=j?.state==='complete'?`<figure class="card"><img class="generated-image" src="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" alt="Ergebnis des Bildtests"><figcaption><a class="link" href="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" download="athena-${e(j.id)}.png">PNG herunterladen</a></figcaption></figure>`:''; el('image-test-result').innerHTML=j?.state==='complete'?`<figure class="card"><img class="generated-image" src="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" alt="Ergebnis des Bildtests"><figcaption><a class="link" href="/api/v1/image-tests/image?id=${encodeURIComponent(j.id)}" download="athena-${e(j.id)}.png">PNG herunterladen</a></figcaption></figure>`:'';
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);}}); 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);}} }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=>`<option value="${p.id}">${e(p.name)}${p.runnable?'':' · Komponenten / Laufzeit prüfen'}</option>`).join('')||'<option value="">Zuerst ein Bildprofil anlegen</option>';selection();}).catch(error=>message(error.message));el('image-test-profile').onchange=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=>`<option value="${p.id}">${e(p.name)}${p.runnable?'':' · Komponenten / Laufzeit prüfen'}</option>`).join('')||'<option value="">Zuerst ein Bildprofil anlegen</option>';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{await api('image-tests/start',Object.fromEntries(new FormData(event.target)));message('Bildtest gestartet.');}catch(error){message(error.message);}finally{submitting=false;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(); refresh();
} }
+40 -10
View File
@@ -1,5 +1,7 @@
"""Owned, serial ComfyUI test jobs. Never talks to the production image router.""" """Owned, serial ComfyUI test jobs. Never talks to the production image router."""
import json import json
import base64
import binascii
import os import os
from pathlib import Path from pathlib import Path
import secrets import secrets
@@ -11,7 +13,7 @@ import threading
import time import time
import urllib.request import urllib.request
import uuid 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') PYTHON=Path('/opt/deck-image-python/bin/python')
COMFY=Path('/opt/deck-comfy') 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.') 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] 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': if family=='flux':
return { return {
'1':{'class_type':'UNETLoader','inputs':{'unet_name':'model.safetensors','weight_dtype':'default'}}, '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]}} '13':{'class_type':'ConditioningZeroOut','inputs':{'conditioning':['4',0]}}
} }
return { graph={
'1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}}, '1':{'class_type':'UnetLoaderGGUF','inputs':{'unet_name':'model.gguf'}},
'2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors'}}, '2':{'class_type':'DeckTextEncoderLoader','inputs':{'clip_name':'encoder.safetensors'}},
'3':{'class_type':'VAELoader','inputs':{'vae_name':'vae.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]}}, '7':{'class_type':'VAEDecode','inputs':{'samples':['6',0],'vae':['3',0]}},
'8':{'class_type':'SaveImage','inputs':{'filename_prefix':'result','images':['7',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 0<len(raw)<=10*1024*1024:raise ValueError('Referenzbild muss 1 bis 10 MiB groß sein.')
kind=value.split(';',1)[0].split('/')[-1]
signatures={'png':b'\x89PNG\r\n\x1a\n','jpeg':b'\xff\xd8\xff','webp':b'RIFF'}
if not raw.startswith(signatures[kind]) or kind=='webp' and raw[8:12]!=b'WEBP':raise ValueError('Dateiinhalt entspricht nicht dem Bildformat.')
result.append((raw,'jpg' if kind=='jpeg' else kind))
return result
class ImageTests: class ImageTests:
def __init__(self,root,profiles): def __init__(self,root,profiles):
@@ -100,12 +124,12 @@ class ImageTests:
return {'cancellation_requested':True} return {'cancellation_requested':True}
def model_path(self,item): def model_path(self,item):
return (self.profiles.catalog.root/item['id']/('model'+Path(item['file']).suffix)).resolve() return (self.profiles.catalog.root/item['id']/('model'+Path(item['file']).suffix)).resolve()
def start(self,profile_id,prompt,wait=False,reserved=False): def start(self,profile_id,prompt,wait=False,reserved=False,reference_images=None):
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.') if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.')
release=(lambda:None) if reserved else self.acquire(wait) release=(lambda:None) if reserved else self.acquire(wait)
try:return self._start(profile_id,prompt,release) try:return self._start(profile_id,prompt,release,reference_images or [])
except Exception:release();raise except Exception:release();raise
def _start(self,profile_id,prompt,release): def _start(self,profile_id,prompt,release,reference_images):
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.') if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.')
with self.lock: with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.') if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.')
@@ -115,6 +139,7 @@ class ImageTests:
if not profile or profile['kind']!='image' or not profile['model']:raise ValueError('Bildprofil nicht verfügbar.') if not profile or profile['kind']!='image' or not profile['model']:raise ValueError('Bildprofil nicht verfügbar.')
model=profile['model'] model=profile['model']
if not image_recipe(model):raise ValueError('Für dieses Bildmodell fehlt ein Komponentenrezept.') if not image_recipe(model):raise ValueError('Für dieses Bildmodell fehlt ein Komponentenrezept.')
references=decode_references(reference_images,image_capabilities(model)['reference_images'])
params=profile['parameters'] params=profile['parameters']
if params['width']>1024 or params['height']>1024:raise ValueError('Der isolierte Test unterstützt maximal 1024 × 1024 Pixel.') 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')) 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) 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.') 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) 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.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),daemon=True).start() 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) 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 directory=self.root/job_id;process=None
try: try:
directory.mkdir(mode=0o700) directory.mkdir(mode=0o700)
for role,item,filename in [('unet',model,'model.safetensors' if model['repo']==FLUX_REPO else 'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]: for role,item,filename in [('unet',model,'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)) 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() 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) custom=directory/'custom_nodes'/'deck_encoder';custom.mkdir(parents=True)
shutil.copyfile(Path(__file__).parent/'image_encoder_node.py',custom/'__init__.py') 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'}} 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.') if time.monotonic()>deadline:raise ValueError('Bildlaufzeit wurde nicht rechtzeitig bereit.')
time.sleep(1) time.sleep(1)
nodes=request('/object_info') 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()} 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.') 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') self._phase('Auftrag wird verarbeitet · Textencoder auf RTX 3060 · Bildberechnung auf RTX 5080')
@@ -203,6 +231,8 @@ class ImageTests:
except ProcessLookupError:pass except ProcessLookupError:pass
with self.lock: with self.lock:
self.process=None;self.job.update(state=final_state,phase=phase,finished_at=time.time());self._save() 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() finally:release()
def image(self,job_id): def image(self,job_id):
with self.lock: with self.lock:
+34
View File
@@ -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<length<=MAX_BODY or not content_type.lower().startswith('multipart/form-data;'):raise ValueError('Multipart-Upload mit höchstens vier Bildern zu je 10 MiB erforderlich.')
raw=handler.rfile.read(length)
if len(raw)!=length:raise ValueError('Upload unvollständig.')
message=BytesParser(policy=policy.default).parsebytes(b'Content-Type: '+content_type.encode()+b'\r\nMIME-Version: 1.0\r\n\r\n'+raw)
if not message.is_multipart() or message.defects:raise ValueError('Ungültiger Multipart-Upload.')
fields={};images=[]
for part in message.iter_parts():
name=part.get_param('name',header='content-disposition');data=part.get_payload(decode=True)
if not isinstance(data,bytes):raise ValueError('Ungültiges Upload-Feld.')
if name in ('image','image[]'):
if len(images)>=4 or not 0<len(data)<=MAX_IMAGE:raise ValueError('Höchstens vier Referenzbilder bis je 10 MiB erlaubt.')
mime=part.get_content_type().lower()
if mime not in ('image/png','image/jpeg','image/webp'):raise ValueError('Nur PNG, JPEG oder WebP erlaubt.')
images.append('data:'+mime+';base64,'+base64.b64encode(data).decode('ascii'))
else:
if name not in (profile_field,'prompt','size','n','response_format') or name in fields or len(data)>4096: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
+5
View File
@@ -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 if model['repo']==FLUX_REPO and model['file']=='Flux.2 Klein-9B_fp16_nsfw.safetensors':return FLUX_COMPONENTS
return None 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_REPO='Lightricks/LTX-2.5'
LTX_FILE='diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors' LTX_FILE='diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors'
LTX_COMPONENTS={ LTX_COMPONENTS={
@@ -88,6 +92,7 @@ class Profiles:
if p['kind']=='chat':p['parameters']={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**p['parameters']} if p['kind']=='chat':p['parameters']={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**p['parameters']}
try: try:
p['model']=self.catalog.entry(p['model_id']) 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.'] p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.']
if p['kind']=='image' and (image_recipe(p['model']) or 'qwen-image-2.1' in p['model']['repo'].lower()): if p['kind']=='image' and (image_recipe(p['model']) 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.'] p['blockers']=['Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.']
+5
View File
@@ -446,6 +446,11 @@ class Handler(BaseHTTPRequestHandler):
except OSError:return self.respond({'error':'Installation konnte nicht vorbereitet werden.'},503) 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'): if self.path in ('/api/v1/image-tests/start','/api/v1/image-tests/cancel'):
try: 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() data=self.read_json()
if self.path.endswith('/cancel') and not data:return self.respond(self.server.image_tests.stop()) 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)) if self.path.endswith('/start') and set(data)=={'profile_id','prompt'}:return self.respond(self.server.image_tests.start(**data))
+16 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch,Mock 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): class ImageTestTests(unittest.TestCase):
def test_workflow_uses_selected_profile_and_owned_aliases(self): 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']['steps'],12)
self.assertEqual(graph['6']['inputs']['cfg'],1.5) self.assertEqual(graph['6']['inputs']['cfg'],1.5)
self.assertEqual(graph['5']['inputs']['height'],768) 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): def test_flux_graph_and_offload(self):
p=dict(width=512,height=512,steps=4,guidance=1) p=dict(width=512,height=512,steps=4,guidance=1)
graph=workflow('a red bicycle',p,42,'flux') graph=workflow('a red bicycle',p,42,'flux')
+21
View File
@@ -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()