File size: 8,082 Bytes
261e6ac | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 | """studio_daemon.py - resident model server for LYRA STUDIO.
Keeps each specialist loaded in VRAM and executes jobs from BASE/jobs.
Writes BASE/results/<id>.json. No time limit."""
import json, os, re, sys, time, traceback
from pathlib import Path
sys.path.insert(0, "/marimo/ai_studio")
import torch
from studio_core import (BASE, CHARACTER, VOICE, SEED, DTYPE_NAME, JOBS, JOBS_DONE, RESULTS, LOGS,
build_image_prompt, load_refinements, save_refinements)
DTYPE = getattr(torch, DTYPE_NAME)
MODELS = {}
PAINTER_ID = "Qwen/Qwen-Image"
CRITIC_ID = "Qwen/Qwen2.5-VL-7B-Instruct"
def log(m):
line = f"[{time.strftime('%H:%M:%S')}] {m}"
print(line, flush=True)
with open(LOGS/"daemon.log", "a") as f: f.write(line + "\n")
def vram():
free, total = torch.cuda.mem_get_info(); return f"{free/1e9:.0f}/{total/1e9:.0f}GB"
# ---------- specialists ----------
def painter():
if "painter" not in MODELS:
from diffusers import QwenImagePipeline
log("load Qwen-Image (concept artist) ...")
p = QwenImagePipeline.from_pretrained(PAINTER_ID, torch_dtype=DTYPE)
p.to("cuda"); p.set_progress_bar_config(disable=True); MODELS["painter"] = p
log(f"painter ready {vram()}")
return MODELS["painter"]
def brain():
if "brain" not in MODELS:
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
log("load Qwen2.5-VL-7B (critic + director brain) ...")
m = Qwen2_5_VLForConditionalGeneration.from_pretrained(CRITIC_ID, torch_dtype=DTYPE, device_map="cuda")
pr = AutoProcessor.from_pretrained(CRITIC_ID)
MODELS["brain"] = (m, pr); log(f"brain ready {vram()}")
return MODELS["brain"]
# ---------- helpers ----------
def _json_block(s):
m = re.search(r"\{.*\}", s, re.S)
if not m: return None
try: return json.loads(m.group(0))
except Exception:
try: return json.loads(m.group(0).replace("'", '"'))
except Exception: return None
def _gen(model, proc, msgs, images=None, max_new_tokens=900):
text = proc.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
kw = {"text": [text], "return_tensors": "pt"}
if images: kw["images"] = images
inputs = proc(**kw).to("cuda")
with torch.inference_mode():
out = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
return proc.batch_decode(out[:, inputs.input_ids.shape[1]:], skip_special_tokens=True)[0].strip()
def _render_one(j, refinements, name):
pipe = painter()
prompt = build_image_prompt(j.get("shot",""), j.get("outfit",""), j.get("mood",""), refinements)
g = torch.Generator(device="cuda").manual_seed(j.get("seed", SEED))
w, h = j.get("w",1024), j.get("h",1536)
log(f"render {name} {w}x{h} steps={j.get('steps',30)} {vram()}")
t0 = time.time()
img = pipe(prompt=prompt, negative_prompt=CHARACTER["negative"], width=w, height=h,
num_inference_steps=j.get("steps",30), true_cfg_scale=j.get("cfg",4.0),
generator=g).images[0]
out = BASE/"assets/img"/name; img.save(out)
dt = time.time()-t0; log(f"saved {name} in {dt:.1f}s")
return {"file": str(out), "prompt": prompt, "seconds": round(dt,1)}
RUBRIC = ("You are the art director of a virtual-idol IP. Be specific and a little ruthless. "
"Judge the render against the CHARACTER BIBLE. Return STRICT JSON only, keys: "
"score_identity (0-10), score_beauty (0-10), score_brief (0-10), "
"problems (list), fixes (list of concrete prompt phrases to ADD to improve the next render), "
"verdict (one sentence).")
def _critique_one(name, task, max_new_tokens=900):
from PIL import Image
model, proc = brain()
img = Image.open(BASE/"assets/img"/name).convert("RGB")
user = (f"CHARACTER BIBLE:\n{json.dumps(CHARACTER, ensure_ascii=False, indent=1)}\n\n"
f"SHOT BRIEF: {task or 'Critique this render of LYRA.'}\n\n{RUBRIC}")
reply = _gen(model, proc, [{"role":"user","content":[{"type":"image","image":img},
{"type":"text","text":user}]}],
images=[img], max_new_tokens=max_new_tokens)
return reply, _json_block(reply)
# ---------- jobs ----------
def job_image(j):
rf = j.get("refinements")
r = _render_one(j, rf, j["name"])
return {"files": [r["file"]], "prompt": r["prompt"], "seconds": r["seconds"]}
def job_critique(j):
reply, parsed = _critique_one(j["image"], j.get("task",""))
return {"image": j["image"], "critique": reply, "parsed": parsed}
def job_direct(j):
model, proc = brain()
log(f"direct: {j['instruction'][:60]}...")
reply = _gen(model, proc, [{"role":"user","content":[{"type":"text","text":j["instruction"]}]}],
max_new_tokens=j.get("max_new_tokens", 900))
return {"reply": reply}
def job_iterate(j):
"""render -> critique -> append fixes (learned) -> render again."""
rf = load_refinements()
history, learned = [], []
task = j.get("task") or f"Canonical identity shot: {j.get('shot','portrait')}"
for r in range(int(j.get("rounds", 2))):
name = j["name"] if r == 0 else j["name"].replace(".png", f"_r{r}.png")
res = _render_one(j, rf, name)
reply, parsed = _critique_one(name, task)
fixes = (parsed or {}).get("fixes", []) if isinstance(parsed, dict) else []
scores = {k: (parsed or {}).get(k) for k in ("score_identity","score_beauty","score_brief")} if isinstance(parsed, dict) else {}
history.append({"round": r, "file": res["file"], "prompt": res["prompt"],
"critique": reply, "parsed": parsed, "scores": scores})
log(f" round {r}: {scores}")
if fixes: rf = rf + fixes; learned = learned + fixes
if j.get("learn", True) and learned:
save_refinements(load_refinements() + learned)
log(f"learned {len(learned)} refinements -> {len(load_refinements())} total")
return {"history": history, "final": history[-1]["file"],
"refinements_file": str(load_refinements())}
def job_evict(j):
"""Free VRAM so another specialist can use the GPU. The studio shares one card."""
which = j.get("which", "all")
names = list(MODELS) if which == "all" else ([which] if isinstance(which, str) else list(which))
for n in names: MODELS.pop(n, None)
import gc; gc.collect(); torch.cuda.empty_cache()
log(f"evicted {names} -> {vram()}")
return {"evicted": names, "vram": vram()}
def job_shutdown(j):
Path(LOGS/"daemon.stop").write_text("1"); return {"bye": True}
HANDLERS = {"image": job_image, "critique": job_critique, "direct": job_direct,
"iterate": job_iterate, "evict": job_evict,
"ping": lambda j: {"pong": True}, "shutdown": job_shutdown}
def main():
(LOGS/"daemon.stop").unlink(missing_ok=True)
log(f"daemon up pid={os.getpid()} cuda={torch.cuda.get_device_name(0)} {vram()}")
while not (LOGS/"daemon.stop").exists():
found = False
for f in sorted(JOBS.glob("*.json")):
found = True; jid = f.stem
try:
j = json.loads(f.read_text()); jid = j.get("id", jid)
h = HANDLERS.get(j.get("type"))
if h is None: # not ours (e.g. a tts job) -> leave it for another daemon
time.sleep(0.5); continue
res = h(j)
res = {"id": jid, "status": "ok", **(res or {})}
except Exception as e:
res = {"id": jid, "status": "error", "error": f"{type(e).__name__}: {e}",
"trace": traceback.format_exc()[-1500:]}
log("FAILED " + res["error"])
tmp = RESULTS/f".{res['id']}.tmp"; tmp.write_text(json.dumps(res, ensure_ascii=False, indent=2))
tmp.rename(RESULTS/f"{res['id']}.json"); f.rename(JOBS_DONE/f.name)
log(f"done {res['id']} status={res['status']}")
if not found: time.sleep(1)
if __name__ == "__main__":
main()
|