Add optional per-image-profile Qwen prompt enhancers

This commit is contained in:
Mikei386
2026-09-29 21:16:40 +02:00
parent 7ba17308f5
commit c5a028cba9
13 changed files with 363 additions and 19 deletions
+8
View File
@@ -194,6 +194,14 @@ Chat akzeptiert `reasoning_effort`: `none`, `minimal`, `low`, `medium`, `high`,
Die Bildgröße wird aus dem Profil übernommen. `size` (z. B. `1536x1024` oder `auto`) ist ein Wunsch und verändert weder Profil noch Speicherbedarf. Die JSON-Antwort enthält zusätzlich `athena_deck.size`, `requested_size` und `size_policy: profile`. Es erfolgt keine automatische Skalierung oder Änderung des Seitenverhältnisses. In Hermes einmalig `image_gen.openai.model: athena-image` setzen. Die Bildgröße wird aus dem Profil übernommen. `size` (z. B. `1536x1024` oder `auto`) ist ein Wunsch und verändert weder Profil noch Speicherbedarf. Die JSON-Antwort enthält zusätzlich `athena_deck.size`, `requested_size` und `size_policy: profile`. Es erfolgt keine automatische Skalierung oder Änderung des Seitenverhältnisses. In Hermes einmalig `image_gen.openai.model: athena-image` setzen.
### Optionale Bild-Prompt-Aufbereitung
Unter **Bildgenerierung → Profile → Prompt-Aufwerter** kann jedes Bildprofil Text-zu-Bild und Bildbearbeitung mit Referenzbildern getrennt ein- oder ausschalten. Aktuell sind nur die offiziellen `Qwen/Qwen-Image-2.1-PE-T2I` und `Qwen/Qwen-Image-2.1-PE-I2I` als ausführbare Rezepte hinterlegt. Die Modellkarten und die Qwen Research License sind im Dialog verlinkt; beide Downloads sind jeweils etwa 18,8 GB groß. Eine Zuordnung zu anderen Bildfamilien ist möglich, ihre Bildqualität wird dabei nicht zugesichert. Uninstallierte Aufwerter erscheinen als Profilblocker.
Deck lädt den Aufwerter bei Bedarf vor dem Bildworker, führt ihn mit 5080/3060 und bei Bedarf CPU-Auslagerung aus und beendet den Prozess vor dem Start von ComfyUI. Eine CPU-only-Wahl ist möglich, aber sehr langsam. Die generierte `wh_ratio` bleibt ein Vorschlag; die gespeicherte Bildauflösung wird nicht automatisch geändert. Ein Fehler der Aufbereitung beendet den Auftrag sichtbar, statt still den Originalprompt zu verwenden. Der Bildtest zeigt über **Prompt-Aufbereitung ansehen** Original und Vorschlag; „Übernehmen“ oder „Original behalten“ überspringt die erneute Aufbereitung bei genau diesem Testauftrag. API-Bildaufträge verwenden die Profilwahl automatisch.
Verwaltungs-API: `GET /api/v1/prompt-enhancers` (Installation und Vorschau), `POST /api/v1/prompt-enhancers/install` mit `{ "task": "t2i|i2i" }`, `POST /api/v1/prompt-enhancers/cancel` mit `{}`, `POST /api/v1/prompt-enhancers/preview` mit `{ "profile_id": "…", "prompt": "…" }` oder Multipart samt `image[]`; `POST /api/v1/profiles/prompt-enhancer` speichert `{ "id": "…", "revision": 1, "prompt_enhancer": { "t2i": null, "i2i": null, "device": "auto" } }` mit den offiziellen Repository-IDs statt `null` für aktive Aufgaben. Diese Routen erfordern die angemeldete Verwaltungssitzung. Der normale Bildendpunkt behält seine API-Form.
### API-Kompatibilität ### API-Kompatibilität
`api_compat.py` normalisiert Chat-Anfragen unabhängig von Client, Modellprofilen und Prozesssteuerung. Standard-Effortwerte bis `max` werden als Template-Hinweis weitergereicht. Die Erweiterung `ultra` wird auf `max` abgebildet, unabhängig davon, welcher Client sie sendet. Fehlend oder null bleibt ungesetzt; `none` wird unverändert weitergereicht. Es werden keine Tokenbudgets erfunden und keine Modellprofile verändert. `api_compat.py` normalisiert Chat-Anfragen unabhängig von Client, Modellprofilen und Prozesssteuerung. Standard-Effortwerte bis `max` werden als Template-Hinweis weitergereicht. Die Erweiterung `ultra` wird auf `max` abgebildet, unabhängig davon, welcher Client sie sendet. Fehlend oder null bleibt ungesetzt; `none` wird unverändert weitergereicht. Es werden keine Tokenbudgets erfunden und keine Modellprofile verändert.
+2 -2
View File
@@ -8,13 +8,13 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
&& git -C /opt/deck-comfy checkout 8d534945ebd53cff61e8def81757c6a6c1b9cf2d \ && git -C /opt/deck-comfy checkout 8d534945ebd53cff61e8def81757c6a6c1b9cf2d \
&& git clone https://github.com/leejet/ComfyUI-GGUF.git /opt/deck-comfy/custom_nodes/ComfyUI-GGUF \ && git clone https://github.com/leejet/ComfyUI-GGUF.git /opt/deck-comfy/custom_nodes/ComfyUI-GGUF \
&& git -C /opt/deck-comfy/custom_nodes/ComfyUI-GGUF checkout 373048b8403a7820620065210a691263d4da0a61 \ && git -C /opt/deck-comfy/custom_nodes/ComfyUI-GGUF checkout 373048b8403a7820620065210a691263d4da0a61 \
&& /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt \ && /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt accelerate==1.15.0 \
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock && /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/* RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
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 audio_policy.py 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 audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py 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 \
+1 -1
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] FILES = ['prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock']
def run(*args, check=True, interactive=False): def run(*args, check=True, interactive=False):
+27 -4
View File
@@ -1,16 +1,39 @@
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><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 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><input type="hidden" name="skip_enhancer" id="image-skip-enhancer" value="0"><button type="button" class="secondary" id="image-preview-enhancer">Prompt-Aufbereitung ansehen</button><button id="image-generate" disabled>Bild generieren</button><div id="image-enhancer-preview" role="status"></div><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,previewId=null;
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),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';} 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';el('image-preview-enhancer').disabled=submitting||!p?.prompt_enhancer?.[el('image-reference-files').files.length?'i2i':'t2i'];}
async function preview(){
const button=el('image-preview-enhancer'),area=el('image-enhancer-preview');button.disabled=true;area.textContent='Prompt-Aufwerter startet; Bildmodell wartet.';
try{
const form=new FormData();form.set('profile_id',el('image-test-profile').value);form.set('prompt',el('image-test-form').elements.prompt.value);
for(const file of el('image-reference-files').files)form.append('image[]',file);
const response=await fetch('/api/v1/prompt-enhancers/preview',{method:'POST',headers:{'X-Athena-Deck':'1'},body:form});const job=await response.json();
if(!response.ok)throw Error(job.error||'Vorschau konnte nicht starten.');previewId=job.id;
async function poll(){
if(!root.isConnected||previewId!==job.id)return;
const state=(await api('prompt-enhancers')).preview;if(!state||state.id!==job.id)return;
if(state.state==='running'){area.textContent=state.phase;setTimeout(poll,2000);return;}
if(state.state!=='complete')throw Error(state.phase||'Aufbereitung fehlgeschlagen.');
area.innerHTML=`<p><strong>Vorschlag</strong> · Seitenverhältnis: ${e(state.result.wh_ratio||'kein Vorschlag')}</p><textarea id="image-enhanced-text" rows="8" maxlength="4000"></textarea><p><button type="button" id="image-use-enhanced">Vorschlag übernehmen</button><button type="button" class="secondary" id="image-use-original">Original behalten</button></p>`;
area.querySelector('textarea').value=state.result.prompt;
area.querySelector('#image-use-enhanced').onclick=()=>{el('image-test-form').elements.prompt.value=area.querySelector('textarea').value;el('image-skip-enhancer').value='1';area.textContent='Überarbeiteter Prompt übernommen. Bildgenerierung bereitet ihn nicht erneut auf.';};
area.querySelector('#image-use-original').onclick=()=>{el('image-skip-enhancer').value='1';area.textContent='Originalprompt bleibt. Für diesen Bildtest wird die Aufbereitung übersprungen.';};
}
poll().catch(error=>{area.textContent=error.message;});
}catch(error){area.textContent=error.message;}finally{button.disabled=false;}
}
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=()=>{el('image-reference-files').value='';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));const invalidate=()=>{previewId=null;el('image-skip-enhancer').value='0';el('image-enhancer-preview').replaceChildren();};el('image-test-profile').onchange=()=>{el('image-reference-files').value='';invalidate();selection();};
el('image-reference-files').onchange=()=>{invalidate();selection();};
el('image-test-form').elements.prompt.oninput=invalidate;
el('image-preview-enhancer').onclick=preview;
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();}}; 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();
+1 -1
View File
@@ -86,7 +86,7 @@ class ImageRuntime:
self._phase('PyTorch mit CUDA herunterladen · mehrere GiB, kann einige Minuten dauern') self._phase('PyTorch mit CUDA herunterladen · mehrere GiB, kann einige Minuten dauern')
self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchvision==0.26.0','--index-url','https://download.pytorch.org/whl/cu128']) self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchvision==0.26.0','--index-url','https://download.pytorch.org/whl/cu128'])
self._phase('Bildlaufzeit-Abhängigkeiten installieren') self._phase('Bildlaufzeit-Abhängigkeiten installieren')
self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/image-requirements.lock','-r',comfy/'requirements.txt','-r',comfy/'custom_nodes/ComfyUI-GGUF/requirements.txt']) self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/image-requirements.lock','-r',comfy/'requirements.txt','-r',comfy/'custom_nodes/ComfyUI-GGUF/requirements.txt','accelerate==1.15.0'])
self._phase('Installation prüfen') self._phase('Installation prüfen')
self._command([python,'-m','pip','check']) self._command([python,'-m','pip','check'])
self._command([python,'-c','import torch, gguf, safetensors, aiohttp; assert torch.version.cuda']) self._command([python,'-c','import torch, gguf, safetensors, aiohttp; assert torch.version.cuda'])
+12 -5
View File
@@ -124,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,reference_images=None): def start(self,profile_id,prompt,wait=False,reserved=False,reference_images=None,skip_enhancer=False):
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,reference_images or []) try:return self._start(profile_id,prompt,release,reference_images or [],skip_enhancer)
except Exception:release();raise except Exception:release();raise
def _start(self,profile_id,prompt,release,reference_images): def _start(self,profile_id,prompt,release,reference_images,skip_enhancer=False):
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.')
@@ -153,11 +153,18 @@ class ImageTests:
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,reference_count=len(references)) 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() self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release,references,profile,skip_enhancer),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,references=()): def _run(self,job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release=lambda:None,references=(),profile=None,skip_enhancer=False):
directory=self.root/job_id;process=None directory=self.root/job_id;process=None
try: try:
if not skip_enhancer and profile and getattr(self,'enhancer',None) and (profile.get('prompt_enhancer') or {}).get('i2i' if references else 't2i'):
self._phase('Prompt-Aufwerter lädt; Bildmodell wartet')
result=self.enhancer.rewrite(profile,prompt,references,self.cancel)
prompt=result['prompt']
with self.lock:self.job['prompt_enhanced']=True;self.job['prompt_enhancer']=result['source'];self._save()
if self.cancel.is_set():raise InterruptedError()
self._phase('Prompt-Aufwerter entladen; Bildlaufzeit startet')
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))
+1 -1
View File
@@ -27,7 +27,7 @@ def read_image_upload(handler,profile_field='model'):
if mime not in ('image/png','image/jpeg','image/webp'):raise ValueError('Nur PNG, JPEG oder WebP erlaubt.') 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')) images.append('data:'+mime+';base64,'+base64.b64encode(data).decode('ascii'))
else: 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.') if name not in (profile_field,'prompt','size','n','response_format','skip_enhancer') or name in fields or len(data)>4096:raise ValueError('Ungültiges oder doppeltes Formularfeld.')
try:fields[name]=data.decode('utf-8') try:fields[name]=data.decode('utf-8')
except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None
if profile_field not in fields:raise ValueError('Bildprofil fehlt.') if profile_field not in fields:raise ValueError('Bildprofil fehlt.')
+17 -1
View File
@@ -7,14 +7,30 @@ window.ProfilesUI=(()=>{
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],entries=[],panelSequence=0; const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],entries=[],panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;}; const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length; async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${['image','video'].includes(kind)?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`; el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k]||k)}: ${e(['video_device','text_encoder_device'].includes(k)?({auto:'Automatisch · RTX 5080 bevorzugt',same:'Wie Videopipeline'}[v]||gpus.find(g=>g.uuid===v)?.name||v):k==='gpu_reserve_mode'?({auto:'Automatisch',manual:'Manuell',none:'Keine zusätzliche Reserve'}[v]||v):k==='gpu_reserve_mib'?(Object.entries(v).map(([id,n])=>`${gpus.find(g=>g.uuid===id)?.name||id}: ${n} MiB`).join(', ')||'gemäß Reservemodus'):k==='gpu_devices'?(v.length?v.map((id,i)=>`CUDA${i}: ${gpus.find(g=>g.uuid===id)?.name||id}`).join(', '):'Automatisch'):Array.isArray(v)?(v.join(', ')||'Automatisch'):v)}`).join(' · ')}</p>${kind==='image'?`<p>Prompt-Aufwerter: Text-zu-Bild ${p.prompt_enhancer?.t2i?'an':'aus'} · Bildbearbeitung ${p.prompt_enhancer?.i2i?'an':'aus'} · Gerät ${e(p.prompt_enhancer?.device||'auto')}</p>`:''}${CatalogUI.executionHTML(p.execution)}<details><summary>${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><label class="endpoint-choice"><input type="checkbox" data-publish="${p.id}" ${enabled.includes(p.id)?'checked':''} ${!p.runnable&&!enabled.includes(p.id)?'disabled':''}> Am API-Endpunkt freigegeben</label><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${['image','video'].includes(kind)?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}${kind==='image'?`<button class="secondary" data-enhancers="${p.id}">Prompt-Aufwerter</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`); el('profile-list').insertAdjacentHTML('afterbegin',`<p>${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.</p>`);
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}}); root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}}); root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components))); root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers)));
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit))); root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});}); root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});});
}catch(error){message(error.message);}} }catch(error){message(error.message);}}
async function enhancers(profile){
const sequence=++panelSequence,selected=profile.prompt_enhancer||{t2i:null,i2i:null,device:'auto'};
el('profile-editor').innerHTML=`<section class="card editor"><h2>Prompt-Aufwerter · ${e(profile.name)}</h2><p>Optional pro Bildprofil. Text-zu-Bild und Bearbeitung mit Referenzbildern verwenden getrennte offizielle Qwen-Modelle. Andere Bildfamilien können sie nur nach eigener Prüfung sinnvoll nutzen.</p><form id="enhancer-form"><div class="form-grid"><label>Text-zu-Bild<select name="t2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-T2I" ${selected.t2i?'selected':''}>Qwen Image 2.1 PE-T2I</option></select></label><label>Bildbearbeitung mit Referenzen<select name="i2i"><option value="">Aus · Originalprompt verwenden</option><option value="Qwen/Qwen-Image-2.1-PE-I2I" ${selected.i2i?'selected':''}>Qwen Image 2.1 PE-I2I</option></select></label><label>Ausführungsgerät<select name="device">${[['auto','Automatisch · 5080, dann 3060, nötigenfalls RAM'],['5080','RTX 5080 · nötigenfalls RAM'],['3060','RTX 3060 · nötigenfalls RAM'],['cpu','Nur CPU · sehr langsam']].map(([v,t])=>`<option value="${v}" ${selected.device===v?'selected':''}>${t}</option>`).join('')}</select></label></div><p>Der Aufwerter wird vor der Bildpipeline geladen und danach vollständig entladen. Beide PE-Modelle benötigen je etwa 18,8 GB Download. Die Wahl einer einzelnen GPU garantiert keinen rein dortigen Betrieb; CPU-Auslagerung kann erforderlich sein. Die Bildauflösung bleibt im Profil gespeichert; ein Seitenverhältnis aus der PE-Antwort ist nur ein Vorschlag.</p><button>Profil-Einstellung speichern</button><button type="button" class="secondary" id="enhancer-close">Schließen</button></form><h3>Offizielle Modelle installieren</h3><div id="enhancer-models"></div><p id="enhancer-message" role="status"></p></section>`;
const note=el('enhancer-message');el('enhancer-close').onclick=()=>el('profile-editor').replaceChildren();
el('enhancer-form').onsubmit=async event=>{event.preventDefault();const form=event.target,values=new FormData(form),button=form.querySelector('button');button.disabled=true;try{await api('profiles/prompt-enhancer',{id:profile.id,revision:profile.revision,prompt_enhancer:{t2i:values.get('t2i')||null,i2i:values.get('i2i')||null,device:values.get('device')}});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Prompt-Aufwerter im Bildprofil gespeichert.');}catch(error){note.textContent=error.message;}finally{button.disabled=false;}};
async function refresh(){
try{const state=await api('prompt-enhancers');if(!root.isConnected||sequence!==panelSequence)return;
el('enhancer-models').innerHTML=['t2i','i2i'].map(task=>{const item=state.models[task];return `<p><strong>${task.toUpperCase()}</strong> · ${item.installed?'Installiert · '+e(item.revision?.slice(0,12)):'Nicht installiert'} · <a class="link" href="https://huggingface.co/${item.repo}" target="_blank" rel="noopener noreferrer">Modellkarte und Lizenz ↗</a> <button type="button" class="secondary" data-install="${task}" ${item.installed||state.job?.state==='running'?'disabled':''}>Herunterladen</button></p>`;}).join('')+`<p>${e(state.job?.phase||'Kein Download läuft.')}</p>${state.job?.state==='running'?'<button type="button" class="secondary" id="enhancer-cancel">Download abbrechen</button>':''}`;
el('enhancer-models').querySelectorAll('[data-install]').forEach(button=>button.onclick=async()=>{button.disabled=true;try{await api('prompt-enhancers/install',{task:button.dataset.install});note.textContent='Download gestartet. Die Bildmodelle bleiben unberührt.';}catch(error){note.textContent=error.message;}});
el('enhancer-cancel')?.addEventListener('click',async()=>{try{await api('prompt-enhancers/cancel',{});}catch(error){note.textContent=error.message;}});
}catch(error){if(root.isConnected&&sequence===panelSequence)note.textContent=error.message;}
finally{if(root.isConnected&&sequence===panelSequence)setTimeout(refresh,3000);}
}
refresh();el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
}
async function components(profile){ async function components(profile){
const sequence=++panelSequence; const sequence=++panelSequence;
el('profile-editor').innerHTML='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>'; el('profile-editor').innerHTML='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>';
+22 -1
View File
@@ -14,6 +14,8 @@ SCHEMAS={
'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)} 'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)}
} }
VIDEO_DEVICE_DEFAULTS={'video_device':'auto','text_encoder_device':'same'} VIDEO_DEVICE_DEFAULTS={'video_device':'auto','text_encoder_device':'same'}
PROMPT_ENHANCER_REPOS={'t2i':'Qwen/Qwen-Image-2.1-PE-T2I','i2i':'Qwen/Qwen-Image-2.1-PE-I2I'}
PROMPT_ENHANCER_DEFAULTS={'t2i':None,'i2i':None,'device':'auto'}
def video_parameters(params): def video_parameters(params):
params={**VIDEO_DEVICE_DEFAULTS,**params} params={**VIDEO_DEVICE_DEFAULTS,**params}
for key in VIDEO_DEVICE_DEFAULTS: for key in VIDEO_DEVICE_DEFAULTS:
@@ -82,12 +84,14 @@ class Profiles:
def __init__(self,path,catalog): def __init__(self,path,catalog):
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock() self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
self.image_runtime_ready=lambda:False self.image_runtime_ready=lambda:False
self.prompt_enhancer_ready=lambda task:False
self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None
self.rows=json.loads(self.path.read_text()) if self.path.exists() else [] self.rows=json.loads(self.path.read_text()) if self.path.exists() else []
def status(self): def status(self):
with self.lock: with self.lock:
rows=json.loads(json.dumps(self.rows)) rows=json.loads(json.dumps(self.rows))
for p in rows: for p in rows:
if p['kind']=='image':p['prompt_enhancer']={**PROMPT_ENHANCER_DEFAULTS,**p.get('prompt_enhancer',{})}
if p['kind']=='video':p['parameters']={**VIDEO_DEVICE_DEFAULTS,**p['parameters']} if p['kind']=='video':p['parameters']={**VIDEO_DEVICE_DEFAULTS,**p['parameters']}
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:
@@ -111,6 +115,9 @@ class Profiles:
if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p) if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p)
if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p) if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p)
if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.') if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.')
if p['kind']=='image':
for task,repo in PROMPT_ENHANCER_REPOS.items():
if p['prompt_enhancer'].get(task)==repo and not self.prompt_enhancer_ready(task):p['blockers'].append('Prompt-Aufwerter '+task.upper()+' fehlt. Unter Bildgenerierung → Aufwerter installieren.')
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured' p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}} return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}}
def save(self,data): def save(self,data):
@@ -140,12 +147,26 @@ class Profiles:
if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.') if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.')
if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.') if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.')
components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {} components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {}
row=dict(components=components,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time()) row=dict(components=components,prompt_enhancer=existing.get('prompt_enhancer',dict(PROMPT_ENHANCER_DEFAULTS)) if existing and existing['model_id']==model['id'] and kind=='image' else dict(PROMPT_ENHANCER_DEFAULTS) if kind=='image' else None,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time())
rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row] rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row]
self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700) self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700)
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
return row return row
def configure_prompt_enhancer(self,data):
if not isinstance(data,dict) or set(data)!={'id','revision','prompt_enhancer'}:raise ValueError('Ungültige Prompt-Aufwerter-Einstellung.')
config=data['prompt_enhancer']
if not isinstance(config,dict) or set(config)!=set(PROMPT_ENHANCER_DEFAULTS):raise ValueError('Unvollständige Prompt-Aufwerter-Einstellung.')
if any(config[task] not in (None,repo) for task,repo in PROMPT_ENHANCER_REPOS.items()):raise ValueError('Für diese Aufgabe ist der Aufwerter nicht geprüft.')
if config['device'] not in ('auto','5080','3060','cpu'):raise ValueError('Ungültiges Gerät für den Aufwerter.')
with self.lock:
old=next((p for p in self.rows if p['id']==data['id'] and p['kind']=='image'),None)
if not old or data['revision']!=old['revision']:raise ValueError('Bildprofil geändert oder nicht gefunden. Ansicht neu laden.')
updated=dict(old,prompt_enhancer=dict(config),revision=old['revision']+1,updated_at=time.time())
rows=[updated if p is old else p for p in self.rows]
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
return updated
def delete(self,data): def delete(self,data):
if set(data)!={'id','revision'}:raise ValueError('Ungültige Profilfelder.') if set(data)!={'id','revision'}:raise ValueError('Ungültige Profilfelder.')
with self.lock: with self.lock:
+154
View File
@@ -0,0 +1,154 @@
"""Isolated, one-shot Qwen image prompt enhancement owned by Deck."""
import json,os,shutil,signal,subprocess,tempfile,threading,time,uuid
from pathlib import Path
from image_test import probe,cgroup_headroom,decode_references
from image_runtime import BUNDLED
from profiles import PROMPT_ENHANCER_REPOS
WORKER=Path(__file__).with_name('prompt_enhancer_worker.py')
GIB=1024**3
class PromptEnhancer:
def __init__(self,root,catalog,profiles,runtime):
self.root=Path(root);self.catalog=catalog;self.profiles=profiles;self.runtime=runtime
self.lock=threading.RLock();self.cancel=threading.Event();self.process=None;self.job=None;self.preview=None;self.acquire=lambda wait=False:lambda:None
def directory(self,task):return self.root/task
def python(self):return BUNDLED[0] if BUNDLED[0].is_file() else self.runtime.paths()[0]
def installed(self,task):
path=self.directory(task);marker=path/'installed.json'
if not marker.is_file():return None
try:
data=json.loads(marker.read_text());return data if data['repo']==PROMPT_ENHANCER_REPOS[task] and (path/'model.safetensors.index.json').is_file() else None
except (OSError,ValueError,KeyError):return None
def status(self):
with self.lock:
return dict(models={task:dict(repo=repo,installed=bool(self.installed(task)),revision=(self.installed(task) or {}).get('revision')) for task,repo in PROMPT_ENHANCER_REPOS.items()},job=dict(self.job) if self.job else None,preview=dict(self.preview) if self.preview else None)
def install(self,task):
if task not in PROMPT_ENHANCER_REPOS:raise ValueError('Unbekannter Prompt-Aufwerter.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Ein Aufwerter-Download läuft bereits.')
if self.preview and self.preview['state']=='running':raise ValueError('Eine Prompt-Vorschau läuft; zuerst abschließen.')
if self.installed(task):raise ValueError('Dieser Aufwerter ist bereits installiert.')
if not self.runtime.status()['installed']:raise ValueError('Zuerst die Bildlaufzeit einrichten.')
if shutil.disk_usage(self.root if self.root.exists() else self.root.parent).free<30*GIB:raise ValueError('Mindestens 30 GiB freier Plattenspeicher erforderlich.')
source=self.catalog.files(PROMPT_ENHANCER_REPOS[task]);revision=source['revision']
expected=sum(f['size'] for f in source['files'] if f['name'].endswith('.safetensors'))
if expected<15*GIB or expected>25*GIB:raise ValueError('Unerwartete Größe der offiziellen Aufwerter-Gewichte; Rezept prüfen.')
self.root.mkdir(parents=True,exist_ok=True,mode=0o700);self.cancel.clear()
self.job=dict(id=uuid.uuid4().hex,task=task,state='running',phase='Offizielle Dateien werden heruntergeladen',repo=source['repo'],revision=revision,total_bytes=expected,started_at=time.time())
threading.Thread(target=self._download,args=(task,revision),daemon=True).start();return self.status()
def _download(self,task,revision):
target=self.directory(task);target.mkdir(parents=True,exist_ok=True,mode=0o700)
env=dict(os.environ,HF_TOKEN=self.catalog.hub_auth.token() or '',HF_HUB_DISABLE_TELEMETRY='1',HOME=str(self.root),PYTHONDONTWRITEBYTECODE='1')
data=dict(repo=PROMPT_ENHANCER_REPOS[task],revision=revision,directory=str(target))
state='failed';phase='Aufwerter-Download fehlgeschlagen. Netzwerk, Hub-Zugang und freien Speicher prüfen.'
try:
python=self.python()
with self.lock:
if self.cancel.is_set():raise InterruptedError()
self.process=subprocess.Popen([str(python),str(WORKER),'download'],stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,text=True,start_new_session=True,env=env)
process=self.process
process.stdin.write(json.dumps(data));process.stdin.close()
deadline=time.monotonic()+21600
while process.poll() is None:
if self.cancel.wait(1):raise InterruptedError()
if time.monotonic()>deadline:raise ValueError('Download-Zeitlimit erreicht.')
if shutil.disk_usage(target).free<10*GIB:raise ValueError('Plattenreserve unter 10 GiB.')
if self.cancel.is_set():raise InterruptedError()
if process.returncode:raise ValueError('Aufwerter-Dateien konnten nicht vollständig heruntergeladen werden.')
marker=target/'installed.tmp';marker.write_text(json.dumps(data));marker.replace(target/'installed.json')
state='complete';phase='Offizieller Aufwerter installiert. Es läuft kein Modell.'
except InterruptedError:state='cancelled';phase='Download abgebrochen.'
except ValueError as exc:phase=str(exc)
except Exception:pass
finally:
self._terminate()
with self.lock:self.job.update(state=state,phase=phase,finished_at=time.time())
def _terminate(self):
with self.lock:process=self.process;self.process=None
if process and process.poll() is None:
try:
os.killpg(process.pid,signal.SIGTERM)
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
except ProcessLookupError:pass
def stop(self):
self.cancel.set();self._terminate();return {'cancellation_requested':True}
def stop_preview(self):
with self.lock:active=bool(self.preview and self.preview['state']=='running')
if active:self.cancel.set();self._terminate()
return {'cancellation_requested':active}
def _devices(self,choice):
if choice=='cpu':return ''
rows=probe();names=['5080','3060'] if choice=='auto' else [choice];selected=[]
for name in names:
row=next((g for g in rows if ('RTX '+name) in g['name']),None)
if not row or row['processes'] or row['free_mib']<('5080'==name and 10000 or 7000):
if choice=='auto':continue
raise ValueError('Gewählte GPU für Prompt-Aufbereitung nicht frei oder mit zu wenig VRAM.')
selected.append(row['uuid'])
if not selected:raise ValueError('Keine geeignete freie GPU für den Prompt-Aufwerter.')
return ','.join(selected)
def rewrite(self,profile,prompt,references=(),cancel=None):
task='i2i' if references else 't2i';config=profile.get('prompt_enhancer') or {};repo=config.get(task)
if not repo:return dict(prompt=prompt,enhanced=False,wh_ratio='')
if repo!=PROMPT_ENHANCER_REPOS[task]:raise ValueError('Unbekannter Prompt-Aufwerter im Profil.')
if not self.installed(task):raise ValueError('Prompt-Aufwerter fehlt. Unter Bildgenerierung → Aufwerter installieren.')
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Prompt mit 1–4000 Zeichen erforderlich.')
headroom=cgroup_headroom()
if headroom is not None and headroom<22*GIB:raise ValueError('Für den Prompt-Aufwerter sind mindestens 22 GiB freier Deck-RAM nötig.')
device=config.get('device','auto');visible=self._devices(device)
rows={g['uuid']:g for g in probe()}
gpu_limits=[min(12 if '5080' in rows[ident]['name'] else 9,max(4,int(rows[ident]['free_mib']/1024)-2)) for ident in visible.split(',') if ident]
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
with tempfile.TemporaryDirectory(prefix='rewrite-',dir=self.root) as folder:
paths=[]
for i,(raw,extension) in enumerate(references):
path=Path(folder)/f'image-{i}.{extension}';path.write_bytes(raw);paths.append(str(path))
env=dict(os.environ,CUDA_VISIBLE_DEVICES=visible,HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',HF_HUB_DISABLE_TELEMETRY='1',OMP_NUM_THREADS='2',HOME=folder,PYTHONDONTWRITEBYTECODE='1')
data=dict(directory=str(self.directory(task)),task=task,images=paths,prompt=prompt,device=device,gpu_limits=gpu_limits)
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Aufwerter-Download läuft; Bildauftrag danach erneut starten.')
if self.process and self.process.poll() is None:raise ValueError('Ein Prompt-Aufwerter läuft bereits.')
self.process=subprocess.Popen([str(self.python()),str(WORKER),'rewrite'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True,start_new_session=True,env=env)
process=self.process
try:
payload=json.dumps(data);deadline=time.monotonic()+900
while True:
if cancel and cancel.is_set():raise InterruptedError()
if time.monotonic()>deadline:raise ValueError('Prompt-Aufwerter hat das Zeitlimit erreicht.')
try:output,_=process.communicate(payload,timeout=1);break
except subprocess.TimeoutExpired:payload=None
if cancel and cancel.is_set():raise InterruptedError()
if process.returncode:raise ValueError('Prompt-Aufwerter fehlgeschlagen. GPU/RAM, Modell und Laufzeit prüfen; Bild wurde nicht gestartet.')
result=json.loads(output)
if not isinstance(result.get('prompt'),str) or not 1<=len(result['prompt'])<=4000:raise ValueError('Prompt-Aufwerter lieferte keinen gültigen Prompt.')
return dict(result,enhanced=True,source=repo)
finally:self._terminate()
def preview_start(self,profile_id,prompt,reference_images):
profile=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='image'),None)
if not profile:raise ValueError('Bildprofil nicht gefunden.')
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Prompt mit 1–4000 Zeichen erforderlich.')
references=decode_references(reference_images,profile.get('capabilities',{}).get('reference_images',0))
task='i2i' if references else 't2i'
if not (profile.get('prompt_enhancer') or {}).get(task):raise ValueError('Für diese Bildaufgabe ist im Profil kein Prompt-Aufwerter aktiviert.')
if not self.installed(task):raise ValueError('Prompt-Aufwerter zuerst im Bildprofil installieren.')
release=self.acquire(False)
try:
with self.lock:
if self.preview and self.preview['state']=='running':raise ValueError('Eine Prompt-Vorschau läuft bereits.')
if self.job and self.job['state']=='running':raise ValueError('Aufwerter-Download läuft bereits.')
self.cancel.clear();self.preview=dict(id=uuid.uuid4().hex,state='running',phase='Prompt wird aufbereitet',profile_id=profile_id,task=task,started_at=time.time())
threading.Thread(target=self._preview,args=(profile,prompt,references,release),daemon=True).start();return dict(self.preview)
except Exception:release();raise
def _preview(self,profile,prompt,references,release):
try:
result=self.rewrite(profile,prompt,references,self.cancel)
with self.lock:self.preview.update(state='complete',phase='Prompt-Aufbereitung fertig',result=result)
except InterruptedError:
with self.lock:self.preview.update(state='cancelled',phase='Prompt-Aufbereitung abgebrochen')
except ValueError as exc:
with self.lock:self.preview.update(state='failed',phase=str(exc))
except Exception:
with self.lock:self.preview.update(state='failed',phase='Prompt-Aufwerter fehlgeschlagen. Ressourcen und Laufzeit prüfen.')
finally:release()
+57
View File
@@ -0,0 +1,57 @@
"""One-shot official Qwen PE inference or pinned snapshot download.
Runs in Deck's isolated image Python environment. Never retains model weights
after a request; no prompt or image is written to an application log.
"""
import json
import os
import sys
from pathlib import Path
def download(data):
from huggingface_hub import snapshot_download
snapshot_download(repo_id=data['repo'],revision=data['revision'],local_dir=data['directory'],token=os.environ.get('HF_TOKEN') or None,allow_patterns=['*.safetensors','*.json','*.jinja','system_prompt.txt','LICENSE'],max_workers=2)
directory=Path(data['directory'])
required=['model.safetensors.index.json','system_prompt.txt','config.json','tokenizer.json']
if any(not (directory/name).is_file() for name in required) or len(list(directory.glob('model-*.safetensors')))<4:
raise RuntimeError('Prompt-Aufwerter-Dateien unvollständig.')
def rewrite(data):
import torch
from PIL import Image
from transformers import AutoModelForCausalLM,AutoModelForImageTextToText,AutoProcessor,AutoTokenizer
directory=data['directory'];task=data['task'];images=data['images']
system=Path(directory,'system_prompt.txt').read_text().strip()
max_memory=None
if data['device']!='cpu':
if torch.cuda.device_count()!=len(data['gpu_limits']):raise ValueError('Gewählte GPU-Geräte stehen der PE-Laufzeit nicht zur Verfügung.')
max_memory={i:f'{limit}GiB' for i,limit in enumerate(data['gpu_limits'])}
max_memory['cpu']='24GiB'
if task=='t2i':
tokenizer=AutoTokenizer.from_pretrained(directory,local_files_only=True,trust_remote_code=False)
model=AutoModelForCausalLM.from_pretrained(directory,dtype=torch.bfloat16,device_map='auto' if data['device']!='cpu' else 'cpu',max_memory=max_memory,local_files_only=True,trust_remote_code=False).eval()
prompt=tokenizer.apply_chat_template([{'role':'system','content':system},{'role':'user','content':data['prompt']}],tokenize=False,add_generation_prompt=True,enable_thinking=True)
inputs=tokenizer(prompt,return_tensors='pt').to(model.device)
elif task=='i2i':
processor=AutoProcessor.from_pretrained(directory,local_files_only=True,trust_remote_code=False)
model=AutoModelForImageTextToText.from_pretrained(directory,dtype=torch.bfloat16,device_map='auto' if data['device']!='cpu' else 'cpu',max_memory=max_memory,local_files_only=True,trust_remote_code=False).eval()
content=[{'type':'image','image':Image.open(path).convert('RGB')} for path in images]+[{'type':'text','text':data['prompt']}]
inputs=processor.apply_chat_template([{'role':'system','content':[{'type':'text','text':system}]},{'role':'user','content':content}],add_generation_prompt=True,tokenize=True,return_dict=True,return_tensors='pt',enable_thinking=True).to(model.device)
tokenizer=processor.tokenizer
else:raise ValueError('Unbekannte Aufwerter-Aufgabe.')
with torch.inference_mode():
output=model.generate(**inputs,max_new_tokens=8192,do_sample=True,temperature=1.0,top_p=.95,top_k=20)
generated=tokenizer.decode(output[0,inputs['input_ids'].shape[1]:],skip_special_tokens=True)
answer=generated.partition('</think>')[2] or generated
start=answer.find('{');end=answer.rfind('}')
if start<0 or end<=start:raise ValueError('Aufwerter lieferte kein JSON-Ergebnis.')
result=json.loads(answer[start:end+1]);rewritten=result.get('rewritten_prompt')
if not isinstance(rewritten,str) or not 1<=len(rewritten.strip())<=4000:raise ValueError('Überarbeiteter Prompt ist leer oder länger als 4000 Zeichen.')
ratio=result.get('wh_ratio','')
return {'prompt':rewritten.strip(),'wh_ratio':ratio if isinstance(ratio,str) and len(ratio)<=16 else ''}
if __name__=='__main__':
command=sys.argv[1];data=json.load(sys.stdin)
if command=='download':download(data)
elif command=='rewrite':print(json.dumps(rewrite(data),ensure_ascii=False),flush=True)
else:raise ValueError('Unbekannter Aufwerter-Befehl.')
+29 -3
View File
@@ -18,6 +18,7 @@ from capacity import assess, overview
from runtime import Runtime from runtime import Runtime
from image_runtime import ImageRuntime from image_runtime import ImageRuntime
from image_test import ImageTests from image_test import ImageTests
from prompt_enhancer import PromptEnhancer
from docker_support import DockerSupport from docker_support import DockerSupport
from inference import LlamaWorker,Scheduler from inference import LlamaWorker,Scheduler
from endpoint import Endpoint from endpoint import Endpoint
@@ -75,6 +76,9 @@ class Server(ThreadingHTTPServer):
self.image_tests = ImageTests(self.catalog.root.parent/"image-tests",self.profiles) self.image_tests = ImageTests(self.catalog.root.parent/"image-tests",self.profiles)
self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime") self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime")
self.image_tests.runtime = self.image_runtime self.image_tests.runtime = self.image_runtime
self.prompt_enhancer=PromptEnhancer(self.catalog.root.parent/'prompt-enhancers',self.catalog,self.profiles,self.image_runtime)
self.image_tests.enhancer=self.prompt_enhancer
self.profiles.prompt_enhancer_ready=lambda task:bool(self.prompt_enhancer.installed(task))
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"] self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
self.tts_runtime=TTSRuntime(self.catalog.root.parent/'tts-runtime') self.tts_runtime=TTSRuntime(self.catalog.root.parent/'tts-runtime')
self.tts_tests=TTSTests(self.catalog.root.parent/'tts-tests',self.profiles,self.tts_runtime) self.tts_tests=TTSTests(self.catalog.root.parent/'tts-tests',self.profiles,self.tts_runtime)
@@ -95,6 +99,7 @@ class Server(ThreadingHTTPServer):
self.scheduler=Scheduler(self.worker) self.scheduler=Scheduler(self.worker)
self.profiles.chat_blockers=self.worker.blockers self.profiles.chat_blockers=self.worker.blockers
self.image_tests.acquire=self.scheduler.image_reservation self.image_tests.acquire=self.scheduler.image_reservation
self.prompt_enhancer.acquire=self.scheduler.image_reservation
self.tts_tests.acquire=self.scheduler.tts_reservation self.tts_tests.acquire=self.scheduler.tts_reservation
self.scheduler.evict_tts=self.tts_tests.unload_idle self.scheduler.evict_tts=self.tts_tests.unload_idle
self.stt=STT(self.profiles,self.catalog,self.runtime) self.stt=STT(self.profiles,self.catalog,self.runtime)
@@ -105,7 +110,7 @@ class Server(ThreadingHTTPServer):
self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler) self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler)
self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler) self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
def stop_gpu_work(): def stop_gpu_work():
self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.tts_tests.stop();self.worker.stop() self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop()
self.video=Video(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video') self.video=Video(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video')
self.endpoint.video=self.video self.endpoint.video=self.video
if self.endpoint.config['autostart']: if self.endpoint.config['autostart']:
@@ -288,6 +293,7 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/video':return self.respond(self.server.video.status()) if self.path == '/api/v1/video':return self.respond(self.server.video.status())
if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status()) if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status())
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status()) if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
if self.path == '/api/v1/prompt-enhancers':return self.respond(self.server.prompt_enhancer.status())
if urlsplit(self.path).path == '/api/v1/image-tests/image': if urlsplit(self.path).path == '/api/v1/image-tests/image':
try:return self.respond(self.server.image_tests.image(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='image/png') try:return self.respond(self.server.image_tests.image(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='image/png')
except (OSError,ValueError):return self.respond({'error':'Bild nicht verfügbar.'},404) except (OSError,ValueError):return self.respond({'error':'Bild nicht verfügbar.'},404)
@@ -457,14 +463,33 @@ class Handler(BaseHTTPRequestHandler):
if self.path.endswith('/start') and self.headers.get('Content-Type','').startswith('multipart/form-data;'): if self.path.endswith('/start') and self.headers.get('Content-Type','').startswith('multipart/form-data;'):
from image_upload import read_image_upload from image_upload import read_image_upload
fields,images=read_image_upload(self,'profile_id') fields,images=read_image_upload(self,'profile_id')
if set(fields)!={'profile_id','prompt'}:raise ValueError('Ungültige Bildtest-Anfrage.') if set(fields)-{'profile_id','prompt','skip_enhancer'} or not {'profile_id','prompt'}<=set(fields) or fields.get('skip_enhancer','0') not in ('0','1'):raise ValueError('Ungültige Bildtest-Anfrage.')
return self.respond(self.server.image_tests.start(fields['profile_id'],fields['prompt'],reference_images=images)) return self.respond(self.server.image_tests.start(fields['profile_id'],fields['prompt'],reference_images=images,skip_enhancer=fields.get('skip_enhancer')=='1'))
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))
raise ValueError('Ungültige Bildtest-Anfrage.') raise ValueError('Ungültige Bildtest-Anfrage.')
except ValueError as exc:return self.respond({'error':str(exc)},400) except ValueError as exc:return self.respond({'error':str(exc)},400)
except Exception:return self.respond({'error':'Bildtest konnte nicht vorbereitet werden; Ressourcen prüfen.'},503) except Exception:return self.respond({'error':'Bildtest konnte nicht vorbereitet werden; Ressourcen prüfen.'},503)
if self.path == '/api/v1/profiles/prompt-enhancer':
try:return self.respond(self.server.profiles.configure_prompt_enhancer(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Prompt-Aufwerter-Einstellung konnte nicht gespeichert werden.'},503)
if self.path in ('/api/v1/prompt-enhancers/install','/api/v1/prompt-enhancers/cancel','/api/v1/prompt-enhancers/preview'):
try:
enhancer=self.server.prompt_enhancer
if self.path.endswith('/preview') 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('Profil und Prompt erforderlich.')
return self.respond(enhancer.preview_start(fields['profile_id'],fields['prompt'],images))
data=self.read_json()
if self.path.endswith('/install') and set(data)=={'task'}:return self.respond(enhancer.install(data['task']))
if self.path.endswith('/cancel') and not data:return self.respond(enhancer.stop())
if self.path.endswith('/preview') and set(data)=={'profile_id','prompt'}:return self.respond(enhancer.preview_start(data['profile_id'],data['prompt'],[]))
raise ValueError('Ungültige Prompt-Aufwerter-Anfrage.')
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Prompt-Aufwerter nicht erreichbar.'},503)
if self.path == '/api/v1/profiles/components': if self.path == '/api/v1/profiles/components':
try:return self.respond(self.server.profiles.assign(self.read_json())) try:return self.respond(self.server.profiles.assign(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400) except ValueError as exc:return self.respond({'error':str(exc)},400)
@@ -542,6 +567,7 @@ def main():
server.tts_tests.stop() server.tts_tests.stop()
server.tts_runtime.stop() server.tts_runtime.stop()
server.image_runtime.stop() server.image_runtime.stop()
server.prompt_enhancer.stop()
server.image_tests.stop() server.image_tests.stop()
server.runtime.stop() server.runtime.stop()
server.catalog.stop(shutdown=True) server.catalog.stop(shutdown=True)
+32
View File
@@ -0,0 +1,32 @@
import tempfile,unittest
from pathlib import Path
from unittest.mock import Mock,patch
from profiles import Profiles,PROMPT_ENHANCER_REPOS
from prompt_enhancer import PromptEnhancer
class PromptEnhancerTests(unittest.TestCase):
def test_profile_settings_are_persisted_and_validated(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'profiles.json';catalog=Mock();catalog.entry.return_value={'id':'model','kind':'image','repo':'example/image','file':'model.safetensors','profile_eligible':True}
profiles=Profiles(path,catalog)
profiles.rows=[dict(id='p',revision=1,name='Image',kind='image',model_id='model',parameters=dict(width=512,height=512,steps=4,seed=1,guidance=1),components={})]
config=dict(t2i=PROMPT_ENHANCER_REPOS['t2i'],i2i=None,device='auto')
with self.assertRaises(ValueError):profiles.configure_prompt_enhancer(dict(id='p',revision=1,prompt_enhancer=dict(config,t2i='other/model')))
self.assertFalse(path.exists())
saved=profiles.configure_prompt_enhancer(dict(id='p',revision=1,prompt_enhancer=config))
self.assertEqual(saved['revision'],2)
self.assertEqual(Profiles(path,catalog).rows[0]['prompt_enhancer'],config)
with self.assertRaises(ValueError):profiles.configure_prompt_enhancer(dict(id='p',revision=1,prompt_enhancer=config))
def test_rewrite_off_never_starts_worker(self):
with tempfile.TemporaryDirectory() as directory,patch('prompt_enhancer.subprocess.Popen') as spawn:
enhancer=PromptEnhancer(directory,Mock(),Mock(),Mock())
self.assertEqual(enhancer.rewrite({'prompt_enhancer':{'t2i':None}},'a red bicycle')['prompt'],'a red bicycle')
spawn.assert_not_called()
def test_device_selection_does_not_use_busy_gpu(self):
with tempfile.TemporaryDirectory() as directory:
enhancer=PromptEnhancer(directory,Mock(),Mock(),Mock())
rows=[dict(name='RTX 5080',uuid='GPU-5080',processes=0,free_mib=12000),dict(name='RTX 3060',uuid='GPU-3060',processes=1,free_mib=11000)]
with patch('prompt_enhancer.probe',return_value=rows):
self.assertEqual(enhancer._devices('5080'),'GPU-5080')
self.assertEqual(enhancer._devices('auto'),'GPU-5080')
self.assertEqual(enhancer._devices('cpu'),'')