Add profile-aware image reference editing
This commit is contained in:
+10
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -1,17 +1,17 @@
|
|||||||
window.ImageTestUI=(()=>{
|
window.ImageTestUI=(()=>{
|
||||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
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;}
|
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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.']
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user