diff --git a/ENDPOINT.md b/ENDPOINT.md index 6c8db64..9b67f24 100644 --- a/ENDPOINT.md +++ b/ENDPOINT.md @@ -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. diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 49d7fcc..f073545 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 \ diff --git a/deploy/install.py b/deploy/install.py index 6b1001f..6187c2d 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -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): diff --git a/image-test-ui.js b/image-test-ui.js index bc3f07a..f5c1586 100644 --- a/image-test-ui.js +++ b/image-test-ui.js @@ -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 `

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

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

${readonly?'':`

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

`}

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

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

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

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

${readonly?'':`

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

`}

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

`;} function bind(readonly=false){ - const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=false; + const 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=`

Vorschlag · Seitenverhältnis: ${e(state.result.wh_ratio||'kein Vorschlag')}

`; + 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?`${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}

${e(j.profile_name)}

${e(j.phase)}

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

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

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

Noch kein Bildtest gestartet.

`; el('image-test-result').innerHTML=j?.state==='complete'?`
Ergebnis des Bildtests
PNG herunterladen
`:''; el('image-cancel')?.addEventListener('click',async event=>{event.target.disabled=true;try{await api('image-tests/cancel',{});message('Abbruch angefordert; der eigene Bildworker wird beendet.');}catch(error){message(error.message);}}); }selection();}catch(error){message(error.message);}finally{if(root.isConnected)timer=setTimeout(refresh,2000);}} - if(!readonly){api('profiles').then(v=>{if(!root.isConnected)return;profiles=v.profiles.filter(p=>p.kind==='image');el('image-test-profile').innerHTML=profiles.map(p=>``).join('')||'';selection();}).catch(error=>message(error.message));el('image-test-profile').onchange=()=>{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=>``).join('')||'';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(); diff --git a/image_runtime.py b/image_runtime.py index cdd21a9..1b69bb0 100644 --- a/image_runtime.py +++ b/image_runtime.py @@ -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']) diff --git a/image_test.py b/image_test.py index 1222f19..f2c1f4c 100644 --- a/image_test.py +++ b/image_test.py @@ -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)) diff --git a/image_upload.py b/image_upload.py index 9c3ae21..84a1441 100644 --- a/image_upload.py +++ b/image_upload.py @@ -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.') diff --git a/profiles-ui.js b/profiles-ui.js index 21b445f..ac7eb15 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -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=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${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(' · ')}

${CatalogUI.executionHTML(p.execution)}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${['image','video'].includes(kind)?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; + el('profile-list').innerHTML=rows.map(p=>`
GESPEICHERT AUF ATHENA

${e(p.name)}

${e(p.model?.file||'Modelldatei nicht verfügbar')}

${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(' · ')}

${kind==='image'?`

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')}

`:''}${CatalogUI.executionHTML(p.execution)}
${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`

${e(t)}

`).join('')}
${['image','video'].includes(kind)?``:''}${kind==='image'?``:''}
`).join('')||`

Noch keine Profile

${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}

`; el('profile-list').insertAdjacentHTML('afterbegin',`

${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.

`); 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=`

Prompt-Aufwerter · ${e(profile.name)}

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.

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.

Offizielle Modelle installieren

`; + 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 `

${task.toUpperCase()} · ${item.installed?'Installiert · '+e(item.revision?.slice(0,12)):'Nicht installiert'} · Modellkarte und Lizenz ↗

`;}).join('')+`

${e(state.job?.phase||'Kein Download läuft.')}

${state.job?.state==='running'?'':''}`; + 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='

Komponenten werden geprüft …

'; diff --git a/profiles.py b/profiles.py index 88de30c..c50bed8 100644 --- a/profiles.py +++ b/profiles.py @@ -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: diff --git a/prompt_enhancer.py b/prompt_enhancer.py new file mode 100644 index 0000000..e14827d --- /dev/null +++ b/prompt_enhancer.py @@ -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() diff --git a/prompt_enhancer_worker.py b/prompt_enhancer_worker.py new file mode 100644 index 0000000..7ce3d94 --- /dev/null +++ b/prompt_enhancer_worker.py @@ -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('')[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.') diff --git a/server.py b/server.py index 9a252e7..9c316ee 100644 --- a/server.py +++ b/server.py @@ -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) diff --git a/test_prompt_enhancer.py b/test_prompt_enhancer.py new file mode 100644 index 0000000..45e12af --- /dev/null +++ b/test_prompt_enhancer.py @@ -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'),'')