Add optional per-image-profile Qwen prompt enhancers
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user