Add optional per-image-profile Qwen prompt enhancers
This commit is contained in:
@@ -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.
|
||||
|
||||
### 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_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
@@ -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 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 \
|
||||
&& /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
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY deploy/image-requirements.lock /app/deploy/image-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 network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
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):
|
||||
|
||||
+27
-4
@@ -1,16 +1,39 @@
|
||||
window.ImageTestUI=(()=>{
|
||||
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
|
||||
function html(readonly=false){return `<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){
|
||||
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;};
|
||||
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>`;
|
||||
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);}});
|
||||
}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();}};
|
||||
}
|
||||
refresh();
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ class ImageRuntime:
|
||||
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._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._command([python,'-m','pip','check'])
|
||||
self._command([python,'-c','import torch, gguf, safetensors, aiohttp; assert torch.version.cuda'])
|
||||
|
||||
+12
-5
@@ -124,12 +124,12 @@ class ImageTests:
|
||||
return {'cancellation_requested':True}
|
||||
def model_path(self,item):
|
||||
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.')
|
||||
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
|
||||
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.')
|
||||
with self.lock:
|
||||
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.')
|
||||
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._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)
|
||||
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
|
||||
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)
|
||||
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))
|
||||
|
||||
+1
-1
@@ -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.')
|
||||
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.')
|
||||
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')
|
||||
except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None
|
||||
if profile_field not in fields:raise ValueError('Bildprofil fehlt.')
|
||||
|
||||
+17
-1
@@ -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 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;
|
||||
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>`);
|
||||
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-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-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);}}
|
||||
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){
|
||||
const sequence=++panelSequence;
|
||||
el('profile-editor').innerHTML='<section class="card editor"><p role="status">Komponenten werden geprüft …</p></section>';
|
||||
|
||||
+22
-1
@@ -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_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):
|
||||
params={**VIDEO_DEVICE_DEFAULTS,**params}
|
||||
for key in VIDEO_DEVICE_DEFAULTS:
|
||||
@@ -82,12 +84,14 @@ class Profiles:
|
||||
def __init__(self,path,catalog):
|
||||
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
|
||||
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.rows=json.loads(self.path.read_text()) if self.path.exists() else []
|
||||
def status(self):
|
||||
with self.lock:
|
||||
rows=json.loads(json.dumps(self.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']=='chat':p['parameters']={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**p['parameters']}
|
||||
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']=='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':
|
||||
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'
|
||||
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):
|
||||
@@ -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 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 {}
|
||||
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]
|
||||
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
|
||||
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):
|
||||
if set(data)!={'id','revision'}:raise ValueError('Ungültige Profilfelder.')
|
||||
with self.lock:
|
||||
|
||||
@@ -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()
|
||||
@@ -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.')
|
||||
@@ -18,6 +18,7 @@ from capacity import assess, overview
|
||||
from runtime import Runtime
|
||||
from image_runtime import ImageRuntime
|
||||
from image_test import ImageTests
|
||||
from prompt_enhancer import PromptEnhancer
|
||||
from docker_support import DockerSupport
|
||||
from inference import LlamaWorker,Scheduler
|
||||
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_runtime = ImageRuntime(self.catalog.root.parent/"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.tts_runtime=TTSRuntime(self.catalog.root.parent/'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.profiles.chat_blockers=self.worker.blockers
|
||||
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.scheduler.evict_tts=self.tts_tests.unload_idle
|
||||
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.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
|
||||
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.endpoint.video=self.video
|
||||
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/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/prompt-enhancers':return self.respond(self.server.prompt_enhancer.status())
|
||||
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')
|
||||
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;'):
|
||||
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))
|
||||
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,skip_enhancer=fields.get('skip_enhancer')=='1'))
|
||||
data=self.read_json()
|
||||
if self.path.endswith('/cancel') and not data:return self.respond(self.server.image_tests.stop())
|
||||
if self.path.endswith('/start') and set(data)=={'profile_id','prompt'}:return self.respond(self.server.image_tests.start(**data))
|
||||
raise ValueError('Ungültige Bildtest-Anfrage.')
|
||||
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)
|
||||
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':
|
||||
try:return self.respond(self.server.profiles.assign(self.read_json()))
|
||||
except ValueError as exc:return self.respond({'error':str(exc)},400)
|
||||
@@ -542,6 +567,7 @@ def main():
|
||||
server.tts_tests.stop()
|
||||
server.tts_runtime.stop()
|
||||
server.image_runtime.stop()
|
||||
server.prompt_enhancer.stop()
|
||||
server.image_tests.stop()
|
||||
server.runtime.stop()
|
||||
server.catalog.stop(shutdown=True)
|
||||
|
||||
@@ -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'),'')
|
||||
Reference in New Issue
Block a user