studio: unique output prefix per dispatch + done-only result display — a queued job can never show or claim an earlier run's rejected file

The queue detail showed a prior (rejected) render as a queued job's result:
re-fixing the same source reused the same SaveImage prefix, and every view
prefix-globbed disk regardless of row status. Each dispatch now appends a
6-hex job tag to its prefix (fix/compose/amend), rows expose an output only
once THEY are done, and the workflow route runs the same finalize mechanism.

Also routes view/pose-change instructions (from the side, back view, turn
around…) to the free-latent edit variant: the anchored fix latent encodes
the base — deliberately structure-preserving — and its prompt tail pinned
'same pose, framing', which contradicted exactly these asks. Repose asks now
get EmptySD3LatentImage at the reference's aspect and a tail that frees the
pose while keeping model/outfit/hair/lighting.

Feedback records now carry the judged output's curation status, so a
flagged/archived output is negative-only signal downstream — never a
positive training exemplar.
This commit is contained in:
Hanzo AI
2026-07-17 19:14:46 -07:00
parent 701d166b3f
commit 76af4f30e8
2 changed files with 99 additions and 12 deletions
+65 -12
View File
@@ -174,6 +174,10 @@ def _append_feedback(request, root: Path, org: str, key: str, entry: dict) -> No
rec = {"ts": int(time.time()), "user": _owner_of(request), "org": org, "key": key,
"output": path, "vote": entry.get("vote"), "stars": entry.get("stars"),
"text": entry.get("notes") or ""}
# The output's curation state rides along: a flagged/deleted output is
# negative-only signal downstream — never a positive training exemplar.
if path and root is not None:
rec["status"] = _asset_status(root, path)
if path:
p = _safe_asset(root, path)
if p is not None:
@@ -271,7 +275,38 @@ def _reseed(graph: dict) -> dict:
return graph
def _fix_graph(ref_name: str, instruction: str, prefix: str) -> dict:
def _job_tag() -> str:
"""Six hex chars appended to every SaveImage prefix so each dispatch owns a
UNIQUE output namespace — a re-fix of the same source can never prefix-match an
earlier run's (possibly rejected) file as its own result."""
return uuid.uuid4().hex[:6]
# View/pose-change intent: these instructions need the empty-latent reference
# family — the anchored fix latent (VAEEncode of the base) preserves composition
# by design, which is exactly wrong when the user asks for a different view.
_REPOSE = re.compile(
r"\b(from the (side|back|front|left|right)|side view|back view|front view|profile|"
r"three.?quarter|turn(ed|s|ing)? (around|left|right)|facing (left|right|away)|"
r"different (pose|angle|view)|change (the )?(pose|angle|view)|from behind|"
r"standing|sitting|kneeling|walking|lying|looking (left|right|away|back|over))\b", re.I)
def _repose_size(p: Path) -> tuple[int, int]:
"""EmptySD3LatentImage dims for a view/pose change: the reference's aspect at
~1MP, both sides multiples of 16 (latent stride)."""
try:
from PIL import Image
w, h = Image.open(p).size
except Exception:
return 1024, 1536
scale = (1_048_576 / max(1, w * h)) ** 0.5
def to16(v: float) -> int:
return max(256, min(2048, int(round(v * scale / 16)) * 16))
return to16(w), to16(h)
def _fix_graph(ref_name: str, instruction: str, prefix: str, *, size: tuple[int, int] | None = None) -> dict:
"""The canonical Qwen-Image-Edit-2511 instruction edit — ComfyUI's OWN
image_qwen_image_edit template, node for node. FluxKontextImageScale sizes the
reference ONCE; that SAME scaled image feeds both TextEncodeQwenImageEditPlus
@@ -286,9 +321,18 @@ def _fix_graph(ref_name: str, instruction: str, prefix: str) -> dict:
native reference handling (TextEncodeQwenImageEditPlus alone) renders clean.
Feeding the SAME scaled image to conditioning and latent is what also keeps the
generation from insetting the scene in a frame-within-frame border."""
prompt = (f"{instruction.strip()}. Photorealistic, keep the same model, "
f"pose, framing, lighting and background; only apply the "
f"requested change.")
if size is None:
prompt = (f"{instruction.strip()}. Photorealistic, keep the same model, "
f"pose, framing, lighting and background; only apply the "
f"requested change.")
latent = {"class_type": "VAEEncode", "inputs": {"pixels": ["2", 0], "vae": ["5", 0]}}
else:
# A view/pose change must NOT pin pose/framing — and needs a free latent.
prompt = (f"{instruction.strip()}. Photorealistic, keep the same model, "
f"outfit, hair, lighting and background; render the requested "
f"view or pose faithfully.")
latent = {"class_type": "EmptySD3LatentImage",
"inputs": {"width": size[0], "height": size[1], "batch_size": 1}}
return {
"1": {"class_type": "LoadImage", "inputs": {"image": ref_name}},
"2": {"class_type": "FluxKontextImageScale", "inputs": {"image": ["1", 0]}},
@@ -297,7 +341,7 @@ def _fix_graph(ref_name: str, instruction: str, prefix: str) -> dict:
"5": {"class_type": "VAELoader", "inputs": {"vae_name": _QWEN_VAE}},
"6": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": prompt}},
"7": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["3", 0], "vae": ["5", 0], "image1": ["2", 0], "prompt": _NEG}},
"10": {"class_type": "VAEEncode", "inputs": {"pixels": ["2", 0], "vae": ["5", 0]}},
"10": latent,
"11": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["4", 0], "shift": 3.1}},
"12": {"class_type": "CFGNorm", "inputs": {"model": ["11", 0], "strength": 1.0}},
"13": {"class_type": "KSampler", "inputs": {"model": ["12", 0], "positive": ["6", 0], "negative": ["7", 0], "latent_image": ["10", 0],
@@ -616,7 +660,9 @@ async def dispatch_fix(request: web.Request, server, org: str, rel: str,
ref_name = f"fix_src_{abs(hash((str(p), p.stat().st_mtime))) % 10**10}.png"
(in_dir / ref_name).write_bytes(p.read_bytes())
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", p.stem)[:48]
pid = await _dispatch(request, server, org, _fix_graph(ref_name, instruction, f"fixes/{stem}"),
stem = f"{stem}_{_job_tag()}"
size = _repose_size(in_dir / ref_name) if _REPOSE.search(instruction) else None
pid = await _dispatch(request, server, org, _fix_graph(ref_name, instruction, f"fixes/{stem}", size=size),
kind="fix", prompt=instruction, refs=[ref_name],
uploads=[upload] if upload else [], parents=[] if upload else [rel],
output_prefix=f"fixes/{stem}")
@@ -656,7 +702,7 @@ async def dispatch_compose(request: web.Request, server, org: str, paths: list,
ref_names.append(nm)
ref_names.extend(uploaded)
labels = [Path(x).stem for x in paths] + [Path(u).stem for u in uploaded]
stem = "compose_" + "_".join(re.sub(r"[^a-zA-Z0-9]", "", s)[:12] for s in labels[:2])
stem = "compose_" + "_".join(re.sub(r"[^a-zA-Z0-9]", "", s)[:12] for s in labels[:2]) + f"_{_job_tag()}"
pid = await _dispatch(request, server, org, _compose_graph(ref_names, instruction, f"composes/{stem}"),
kind="compose", prompt=instruction, refs=ref_names,
uploads=uploaded, parents=list(paths), output_prefix=f"composes/{stem}")
@@ -708,9 +754,10 @@ async def dispatch_amend(request: web.Request, server, org: str, prompt_id: str,
raise DispatchError("no reference images available to amend")
_cancel_job(server, org, prompt_id)
parents = list(orig.get("parents") or []) + list(paths or [])
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(refs[0]).stem)[:40] + "_amended"
stem = re.sub(r"[^a-zA-Z0-9_-]", "_", Path(refs[0]).stem)[:40] + f"_amended_{_job_tag()}"
if len(refs) == 1:
graph, kind, out = _fix_graph(refs[0], instruction, f"fixes/{stem}"), "fix", f"fixes/{stem}"
size = _repose_size(in_dir / refs[0]) if _REPOSE.search(instruction) else None
graph, kind, out = _fix_graph(refs[0], instruction, f"fixes/{stem}", size=size), "fix", f"fixes/{stem}"
else:
graph, kind, out = _compose_graph(refs, instruction, f"composes/{stem}"), "compose", f"composes/{stem}"
all_uploads = list(dict.fromkeys(list(orig.get("uploads") or []) + new_uploads))
@@ -1066,11 +1113,15 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
embeds the litegraph 'workflow' (UI) and/or the API 'prompt'."""
org = _org_of(request)
pid = request.query.get("id", "")
row = next((r for r in worklog.load(org) if r.get("id") == pid), None)
rows = worklog.load(org)
row = next((r for r in rows if r.get("id") == pid), None)
if row is None:
return web.json_response({"error": "not found"}, status=404)
root = _library_root(org)
landed = _row_output(root, row.get("output_prefix"))
# Same terminal-state mechanism as every other view; only a DONE row owns
# a landed output — a pending row must never open an older sibling's graph.
_finalize_landed(org, root, rows, int(time.time()))
landed = _row_output(root, row.get("output_prefix")) if row.get("status") == "done" else None
p = _safe_asset(root, landed) if (root and landed) else None
if p is None or p.suffix.lower() != ".png":
return web.json_response({"error": "workflow not available yet"}, status=404)
@@ -1669,7 +1720,9 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
out = []
for r in reversed(rows):
row = dict(r)
row["output"] = _row_output(root, r.get("output_prefix"))
# Only a DONE row owns a displayable output. A pending row must never
# show a prefix-matched file from an earlier run (a rejected sibling).
row["output"] = _row_output(root, r.get("output_prefix")) if r.get("status") == "done" else None
out.append(row)
q = (request.query.get("q") or "").lower().strip()
if q:
@@ -158,3 +158,37 @@ def test_clean_base_falls_back_to_nearest_clean_ancestor(org_root):
# a clean base is returned unchanged
assert sh._clean_base(org, root, "root.png") == ("root.png", False)
assert sh._asset_status(root, "fixes/root_00001_.png") == "flagged"
# ── Job identity: every dispatch owns a unique output namespace ──────────────────
def test_job_tag_is_short_hex_and_unique():
a, b = sh._job_tag(), sh._job_tag()
assert len(a) == 6 and int(a, 16) >= 0
assert a != b
# ── View/pose-change routing: repose asks use a free latent, anchored fixes keep
# the encoded-base latent — and the prompt tail never contradicts the ask ─────
def test_fix_graph_anchored_default_keeps_pose():
g = sh._fix_graph("x.png", "fix the coloring", "fixes/x")
assert g["10"]["class_type"] == "VAEEncode"
assert "same model, pose, framing" in g["6"]["inputs"]["prompt"]
def test_fix_graph_repose_uses_empty_latent_and_frees_pose():
g = sh._fix_graph("x.png", "same outfit but from the side only", "fixes/x", size=(1024, 1536))
assert g["10"]["class_type"] == "EmptySD3LatentImage"
assert g["10"]["inputs"]["width"] == 1024 and g["10"]["inputs"]["height"] == 1536
p = g["6"]["inputs"]["prompt"]
assert "pose, framing" not in p # must not pin the pose it was asked to change
assert "view or pose" in p
def test_repose_lexicon_matches_view_changes_only():
hits = ["show me the same model but from the side only", "turn around",
"back view please", "make her facing left", "different angle"]
misses = ["fix the coloring", "brighten the skin slightly", "remove the second strap"]
for t in hits:
assert sh._REPOSE.search(t), t
for t in misses:
assert not sh._REPOSE.search(t), t