Publish snapshot: v1->v2 character port + accumulated fixes
- Tag-builder: port ~1,477 v1 characters into the v2 chip format so the character picker shows 1,486 characters (was 9); 1,390 carry rich per-chip descriptions restructured from each character's own bio. Adds scripts/port_v1_characters_to_v2.py. - Ideogram: rewrite caption to the real Ideogram-4 schema + broaden refusal detection; sampler updates. - Pose-studio, regional-detailer, attention-couple: assorted fixes. - Add validate_templates / test_regions / test_crop_windows scripts.
This commit is contained in:
@@ -783,15 +783,26 @@ function formatRegionInner(inner) {
|
||||
}
|
||||
|
||||
function formatWithRegions(text) {
|
||||
const OPEN = /\$[A-Za-z]\w*\s*\{/g;
|
||||
const OPEN = /\$\w+\s*\{/g; // \w+ to match the compiler/binding rule (digit-leading names too)
|
||||
let result = "";
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = OPEN.exec(text)) !== null) {
|
||||
const openBrace = m.index + m[0].length - 1;
|
||||
const close = text.indexOf("}", openBrace + 1);
|
||||
if (close === -1) continue; // unterminated — leave it for the linter to flag
|
||||
const name = m[0].match(/^\$[A-Za-z]\w*/)[0];
|
||||
// Find the region's close by brace DEPTH, not the first `}` — otherwise a
|
||||
// wildcard brace ({a|b}) inside the body ends the region early, spilling the
|
||||
// rest into the wildcard formatter and splitting a phrase across lines (which
|
||||
// the newline→comma rule then turns into an unwanted comma).
|
||||
let depth = 1, j = openBrace + 1;
|
||||
while (j < text.length && depth > 0) {
|
||||
const ch = text[j];
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") depth--;
|
||||
j++;
|
||||
}
|
||||
if (depth !== 0) continue; // unterminated — leave it for the linter to flag
|
||||
const close = j - 1;
|
||||
const name = m[0].match(/^\$\w+/)[0];
|
||||
const before = text.slice(last, m.index);
|
||||
if (before.trim()) result += formatPromptText(before).trim() + "\n\n";
|
||||
const inner = text.slice(openBrace + 1, close);
|
||||
|
||||
+71
-6
@@ -74,8 +74,48 @@ def strip_comments(text: str) -> str:
|
||||
# =============================================================================
|
||||
# A `$name { body }` group pins `body` to the mannequin identified by `name`
|
||||
# (e.g. $mannequin1). Anything OUTSIDE every group is the shared/global prompt.
|
||||
# Non-greedy body so adjacent groups don't merge; DOTALL so a group spans lines.
|
||||
MANNEQUIN_RE = re.compile(r"\$(\w+)\s*\{(.*?)\}", re.DOTALL)
|
||||
# The close brace is matched by DEPTH (not the first `}`) so a wildcard or
|
||||
# <SCRIPT> brace inside a body ({a|b}) doesn't truncate the region and leak the
|
||||
# rest into the global prompt.
|
||||
_REGION_OPEN_RE = re.compile(r"\$(\w+)\s*\{")
|
||||
|
||||
|
||||
def _iter_region_spans(text: str):
|
||||
"""Yield (name, body, start, end) for each `$name{ ... }` group, matching the
|
||||
close brace by depth so nested braces survive. start..end spans the whole
|
||||
`$name{...}` (braces included) for removing it from the global remainder."""
|
||||
if not text or "$" not in text:
|
||||
return
|
||||
pos = 0
|
||||
while True:
|
||||
m = _REGION_OPEN_RE.search(text, pos)
|
||||
if not m:
|
||||
return
|
||||
depth, i = 1, m.end()
|
||||
while i < len(text) and depth > 0:
|
||||
c = text[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
if depth != 0:
|
||||
# Unbalanced (no matching close): take the rest as the body rather
|
||||
# than loop forever, matching the old regex's graceful give-up.
|
||||
yield m.group(1), text[m.end():], m.start(), len(text)
|
||||
return
|
||||
yield m.group(1), text[m.end():i - 1], m.start(), i
|
||||
pos = i
|
||||
|
||||
|
||||
def _remove_region_spans(text: str, spans) -> str:
|
||||
"""`text` with each (.., start, end) span removed (for the global remainder)."""
|
||||
parts, cursor = [], 0
|
||||
for *_meta, start, end in spans:
|
||||
parts.append(text[cursor:start])
|
||||
cursor = end
|
||||
parts.append(text[cursor:])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def strip_region_markers(text: str) -> str:
|
||||
@@ -87,7 +127,16 @@ def strip_region_markers(text: str) -> str:
|
||||
"""
|
||||
if not text or "$" not in text:
|
||||
return text
|
||||
return MANNEQUIN_RE.sub(lambda m: m.group(2), text)
|
||||
spans = list(_iter_region_spans(text))
|
||||
if not spans:
|
||||
return text
|
||||
parts, cursor = [], 0
|
||||
for _name, body, start, end in spans:
|
||||
parts.append(text[cursor:start])
|
||||
parts.append(body)
|
||||
cursor = end
|
||||
parts.append(text[cursor:])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def compile_regions(text: str, **compile_kwargs) -> dict:
|
||||
@@ -98,12 +147,22 @@ def compile_regions(text: str, **compile_kwargs) -> dict:
|
||||
existing feature. `id` is the trailing integer of the name ($mannequin1 -> 1)
|
||||
for positional figure binding; falls back to 1-based order if name has none.
|
||||
"""
|
||||
groups = [(m.group(1), m.group(2)) for m in MANNEQUIN_RE.finditer(text or "")]
|
||||
global_text = MANNEQUIN_RE.sub("", text or "")
|
||||
# Strip comments FIRST so example $name{} blocks in the // help scaffold
|
||||
# (e.g. "// $alice{ red dress }") aren't extracted as real regions — that
|
||||
# turned a 2-region prompt into a 5-region mess and broke binding.
|
||||
text = strip_comments(text or "")
|
||||
spans = list(_iter_region_spans(text))
|
||||
groups = [(name, body) for name, body, _s, _e in spans]
|
||||
global_text = _remove_region_spans(text, spans) if spans else text
|
||||
g_pos, g_neg, _ = compile_prompt(global_text, **compile_kwargs)
|
||||
|
||||
regions = []
|
||||
for name, body in groups:
|
||||
# A region is ONE figure's description — a continuous phrase, not a lazy
|
||||
# tag-stack. So its line breaks collapse to spaces (a wildcard or phrase
|
||||
# spread across lines reads naturally) instead of the global's
|
||||
# newline→comma. Commas the user types still separate.
|
||||
body = re.sub(r"\s*\n\s*", " ", body)
|
||||
r_pos, _, _ = compile_prompt(body, **compile_kwargs)
|
||||
num = re.search(r"(\d+)$", name)
|
||||
rid = int(num.group(1)) if num else len(regions) + 1
|
||||
@@ -127,6 +186,8 @@ def _pose_entity_index(pose_json: str = "") -> tuple[list[str], int | None]:
|
||||
pose = json.loads(pose_json)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
return [], None
|
||||
if not isinstance(pose, dict):
|
||||
return [], None # json.loads accepts scalars/arrays/null — .get would crash
|
||||
ents = pose.get("regionEntities")
|
||||
if isinstance(ents, list) and ents:
|
||||
names = [str((e.get("name") if isinstance(e, dict) else None) or "").lower()
|
||||
@@ -577,7 +638,11 @@ def _resolve_braces(text: str) -> str:
|
||||
match = re.search(r"\{([^{}]+)\}", text)
|
||||
if not match:
|
||||
break
|
||||
options = [opt.strip() for opt in match.group(1).split("|") if opt.strip()]
|
||||
# Strip whitespace AND leading/trailing commas off each option. Lines are
|
||||
# comma-joined before braces resolve, so a multi-line wildcard can hand us
|
||||
# ", red" or "blue,"; internal commas (a multi-tag option like "a, b") are
|
||||
# kept so {a⏎b|c⏎d} resolves to "a, b" / "c, d".
|
||||
options = [o for o in (opt.strip(" \t\r\n,") for opt in match.group(1).split("|")) if o]
|
||||
replacement = random.choice(options) if options else ""
|
||||
text = text[:match.start()] + replacement + text[match.end():]
|
||||
return text.strip()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Pure crop-window remap for regional / tiled re-sampling.
|
||||
|
||||
Extracted from regional_detailer so the most-revised, most-regressed geometry in
|
||||
the codebase (the moved-content inpaint and per-tile cond + ControlNet-hint
|
||||
remap) is importable and unit-testable WITHOUT the ComfyUI runtime. torch-only,
|
||||
no comfy deps — see scripts/test_crop_windows.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
def crop_hint_to_window(t, win, img_h, img_w, sh, sw, mode="bilinear"):
|
||||
"""Resize a (...,H,W) hint to canvas, crop the detail window, resize to the
|
||||
sampled crop dims — the per-crop remap the tiled upscaler does. Depth/control
|
||||
hints use bilinear; region masks pass mode='nearest-exact' so a hard region
|
||||
edge doesn't soften into a bleed band against the neighbouring region.
|
||||
|
||||
win is (cx1, cy1, cx2, cy2) in canvas pixels. Output last two dims == (sh, sw).
|
||||
"""
|
||||
cx1, cy1, cx2, cy2 = win
|
||||
t = t.float()
|
||||
align = None if mode.startswith("nearest") else False
|
||||
if t.shape[-2:] != (img_h, img_w):
|
||||
t = F.interpolate(t, size=(img_h, img_w), mode=mode, align_corners=align)
|
||||
t = t[..., cy1:cy2, cx1:cx2]
|
||||
return F.interpolate(t, size=(sh, sw), mode=mode, align_corners=align)
|
||||
+40
-4
@@ -69,6 +69,35 @@ async def delete_pose_files(request):
|
||||
return ok_response({"deleted": deleted})
|
||||
|
||||
|
||||
@routes.post("/promptchain/pose-files/pin")
|
||||
async def pin_pose_files(request):
|
||||
"""Mark a content-addressed capture set permanent (write <base>.keep).
|
||||
|
||||
Called when a prompt referencing the set is ENQUEUED, closing the window
|
||||
between queue and the node's execute() (which also pins): during a busy
|
||||
queue, a re-pose would otherwise fire the superseded-delete on a set whose
|
||||
render is still waiting, losing its map + the only copy of its region masks.
|
||||
The delete route already refuses any base carrying a .keep.
|
||||
"""
|
||||
data, err = await parse_json(request)
|
||||
if err:
|
||||
return err
|
||||
base = data.get("base", "")
|
||||
if not _is_hashed(base):
|
||||
return error_response("not a content-addressed pose base")
|
||||
pose_dir = _pose_dir()
|
||||
# Only pin a set whose map is actually on disk — don't litter markers.
|
||||
if not os.path.exists(os.path.join(pose_dir, base + ".png")):
|
||||
return ok_response({"pinned": False})
|
||||
marker = os.path.join(pose_dir, base + ".keep")
|
||||
if not os.path.exists(marker):
|
||||
try:
|
||||
open(marker, "w").close()
|
||||
except OSError as e:
|
||||
return error_response(f"could not pin: {e}")
|
||||
return ok_response({"pinned": True})
|
||||
|
||||
|
||||
# ── imported meshes (.glb/.obj props) ────────────────────────────────────────
|
||||
|
||||
_MESH_EXTS = {".glb", ".gltf", ".obj"}
|
||||
@@ -111,12 +140,14 @@ async def upload_pose_mesh(request):
|
||||
return error_response(f"mesh exceeds {_MESH_MAX_BYTES // (1024 * 1024)}MB")
|
||||
if not data:
|
||||
return error_response("empty upload")
|
||||
name = hashlib.sha256(bytes(data)).hexdigest()[:16] + ext
|
||||
name = hashlib.sha256(data).hexdigest()[:16] + ext # hash the bytearray directly — no full copy
|
||||
mesh_dir = _mesh_dir()
|
||||
os.makedirs(mesh_dir, exist_ok=True)
|
||||
path = os.path.join(mesh_dir, name)
|
||||
if not os.path.exists(path):
|
||||
tmp = path + ".part"
|
||||
# Unique temp so two concurrent uploads of the same content (same path)
|
||||
# don't write/replace each other's .part mid-flight.
|
||||
tmp = f"{path}.part-{os.getpid()}-{os.urandom(4).hex()}"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(data)
|
||||
os.replace(tmp, path)
|
||||
@@ -242,9 +273,14 @@ async def save_pose_preset(request):
|
||||
async def delete_pose_preset(request):
|
||||
name = request.match_info["name"]
|
||||
path = os.path.join(_pose_presets_dir(), _pose_preset_slug(name) + ".json")
|
||||
if not os.path.isfile(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
# Already gone (e.g. a second tab deleted it) — the user's intent is
|
||||
# satisfied; report 404 rather than 500ing on the lost race.
|
||||
return error_response("not found", status=404)
|
||||
os.remove(path)
|
||||
except OSError as e:
|
||||
return error_response(f"could not delete: {e}")
|
||||
return ok_response()
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Regional — Region Boxes",
|
||||
"name": "Region Boxes (caption placement)",
|
||||
"category": "Generation",
|
||||
"order": 1,
|
||||
"scope": {
|
||||
|
||||
@@ -25,10 +25,10 @@ var PromptChainCM=(()=>{var ps=Object.defineProperty;var Ec=Object.getOwnPropert
|
||||
`;for(let d=0;d<f.length;d++){let p=d===0?"":"| ";o+=Wt.repeat(r+1)+p+i(f[d].trim(),r+1)+`
|
||||
`}o+=Wt.repeat(r)+"}"}else o+="{"+c+"}";l=h}else o+=s[l],l++;return o};return i(n,0)}function gm(n){let e=n.split(`
|
||||
`).map(i=>i.trim().replace(/[ \t]{2,}/g," ")),t=[];for(let i of e)i===""&&(t.length===0||t[t.length-1]==="")||t.push(i);for(;t.length&&t[t.length-1]==="";)t.pop();return t.map(i=>i===""?"":Wt+i).join(`
|
||||
`)}function ym(n){let e=/\$[A-Za-z]\w*\s*\{/g,t="",i=0,s;for(;(s=e.exec(n))!==null;){let o=s.index+s[0].length-1,l=n.indexOf("}",o+1);if(l===-1)continue;let a=s[0].match(/^\$[A-Za-z]\w*/)[0],h=n.slice(i,s.index);h.trim()&&(t+=Lo(h).trim()+`
|
||||
`)}function ym(n){let e=/\$\w+\s*\{/g,t="",i=0,s;for(;(s=e.exec(n))!==null;){let o=s.index+s[0].length-1,l=1,a=o+1;for(;a<n.length&&l>0;){let d=n[a];d==="{"?l++:d==="}"&&l--,a++}if(l!==0)continue;let h=a-1,c=s[0].match(/^\$\w+/)[0],f=n.slice(i,s.index);f.trim()&&(t+=Lo(f).trim()+`
|
||||
|
||||
`);let c=n.slice(o+1,l);t+=`${a} {
|
||||
${gm(c)}
|
||||
`);let u=n.slice(o+1,h);t+=`${c} {
|
||||
${gm(u)}
|
||||
}
|
||||
|
||||
`,i=l+1,e.lastIndex=l+1}let r=n.slice(i);return r.trim()&&(t+=Lo(r).trim()),t.trim()}function bm(n){let e=n.state.doc.toString(),t=ym(e);return t!==e&&n.dispatch({changes:{from:0,to:e.length,insert:t}}),!0}var wm=ee.fromClass(class{constructor(n){this.decorations=this.build(n)}update(n){(n.docChanged||n.viewportChanged)&&(this.decorations=this.build(n.view))}build(n){let e=[];for(let{from:t,to:i}of n.visibleRanges){let s=t;for(;s<=i;){let r=n.state.doc.lineAt(s),o=r.text.match(/^(\s+)/);if(o){let l=o[1].length;e.push(R.line({attributes:{style:`padding-left: ${l}ch; text-indent: -${l}ch;`}}).range(r.from))}s=r.to+1}}return R.set(e)}},{decorations:n=>n.decorations}),xm=[O.lineWrapping,wm,O.contentAttributes.of({draggable:"false"}),rh(),vh(),Cc,kc,Sc,Bc.of(Oc),pm,mm,La({position:"fixed"}),Bi.of([{key:"Tab",run:n=>yc(n.state)==="active"?Oo(n):wc(n)},{key:"Shift-Tab",run:xc},{key:"Mod-]",run:wc},{key:"Mod-[",run:xc},{key:"Shift-Alt-f",run:bm},{key:"Mod-s",preventDefault:!0,run:()=>(window.app?.extensionManager?.command?.execute?.("Comfy.SaveWorkflow"),!0)},{key:"Mod-Shift-Enter",preventDefault:!0,run:()=>(window.app?.extensionManager?.command?.execute?.("Comfy.QueuePromptFront"),!0)},{key:"Mod-Enter",preventDefault:!0,run:()=>(window.app?.extensionManager?.command?.execute?.("Comfy.QueuePrompt"),!0)},{key:"Mod-Alt-Enter",preventDefault:!0,run:()=>(window.app?.extensionManager?.command?.execute?.("Comfy.Interrupt"),!0)},...Xh,...Ah])];return Nc(vm);})();
|
||||
`,i=h+1,e.lastIndex=h+1}let r=n.slice(i);return r.trim()&&(t+=Lo(r).trim()),t.trim()}function bm(n){let e=n.state.doc.toString(),t=ym(e);return t!==e&&n.dispatch({changes:{from:0,to:e.length,insert:t}}),!0}var wm=ee.fromClass(class{constructor(n){this.decorations=this.build(n)}update(n){(n.docChanged||n.viewportChanged)&&(this.decorations=this.build(n.view))}build(n){let e=[];for(let{from:t,to:i}of n.visibleRanges){let s=t;for(;s<=i;){let r=n.state.doc.lineAt(s),o=r.text.match(/^(\s+)/);if(o){let l=o[1].length;e.push(R.line({attributes:{style:`padding-left: ${l}ch; text-indent: -${l}ch;`}}).range(r.from))}s=r.to+1}}return R.set(e)}},{decorations:n=>n.decorations}),xm=[O.lineWrapping,wm,O.contentAttributes.of({draggable:"false"}),rh(),vh(),Cc,kc,Sc,Bc.of(Oc),pm,mm,La({position:"fixed"}),Bi.of([{key:"Tab",run:n=>yc(n.state)==="active"?Oo(n):wc(n)},{key:"Shift-Tab",run:xc},{key:"Mod-]",run:wc},{key:"Mod-[",run:xc},{key:"Shift-Alt-f",run:bm},{key:"Mod-s",preventDefault:!0,run:()=>(window.app?.extensionManager?.command?.execute?.("Comfy.SaveWorkflow"),!0)},{key:"Mod-Shift-Enter",preventDefault:!0,run:()=>(window.app?.extensionManager?.command?.execute?.("Comfy.QueuePromptFront"),!0)},{key:"Mod-Enter",preventDefault:!0,run:()=>(window.app?.extensionManager?.command?.execute?.("Comfy.QueuePrompt"),!0)},{key:"Mod-Alt-Enter",preventDefault:!0,run:()=>(window.app?.extensionManager?.command?.execute?.("Comfy.Interrupt"),!0)},...Xh,...Ah])];return Nc(vm);})();
|
||||
|
||||
@@ -1968,14 +1968,25 @@ function injectRegional(pcNode, modelConfig) {
|
||||
if (!LG) return false;
|
||||
|
||||
const downstream = findDownstreamNodes(pcNode);
|
||||
const ksNode = downstream.find(n => n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced");
|
||||
// Samplers that expose positive/negative/model directly so the reroute below
|
||||
// can trace them. Custom/RES4LYF recipes (e.g. krea2 realism's
|
||||
// ClownsharKSampler_Beta) were silently unsupported before — Regional bailed
|
||||
// with a mislabeled "no attachment point" toast.
|
||||
const REGIONAL_SAMPLERS = ["KSampler", "KSamplerAdvanced", "SamplerCustom", "ClownsharKSampler_Beta"];
|
||||
const ksNode = downstream.find(n => REGIONAL_SAMPLERS.includes(n.comfyClass));
|
||||
if (!ksNode) return false;
|
||||
|
||||
const poseNode = (graph._nodes || []).find(n => n.comfyClass === "PromptChain_PoseStudio");
|
||||
// Mask producer: a 3D Poser (preferred — richer scene) OR a Region Box. Both
|
||||
// expose the same MASKS + POSE_JSON contract, so either can drive Regional;
|
||||
// previously only a Poser was accepted, so a Region-Box-only graph couldn't
|
||||
// use the one-click path despite region-box.js documenting parity.
|
||||
const nodes = graph._nodes || [];
|
||||
const poseNode = nodes.find(n => n.comfyClass === "PromptChain_PoseStudio")
|
||||
|| nodes.find(n => n.comfyClass === "PromptChain_RegionBox");
|
||||
if (!poseNode) {
|
||||
app.extensionManager?.toast?.add({
|
||||
severity: "warn", summary: "Add a 3D pose first",
|
||||
detail: "Regional needs a PromptChain 3D Poser in the graph — add 'Depth + 3D pose', pose your figures, then add Regional.",
|
||||
severity: "warn", summary: "Add a pose or region box first",
|
||||
detail: "Regional needs a PromptChain 3D Poser or Region Box in the graph — add one, place your figures/boxes, then add Regional.",
|
||||
life: 7000,
|
||||
});
|
||||
return false;
|
||||
@@ -2042,7 +2053,9 @@ function injectRegional(pcNode, modelConfig) {
|
||||
// $blocks cohere into ONE scene). Other DiTs (Flux/SD3/…) have no ported couple yet, so
|
||||
// they fall back to ComfyUI-native masked conditioning here.
|
||||
const arch = modelConfig?.architecture || "";
|
||||
const NATIVE_REGIONAL_ARCH = new Set(["flux", "flux2", "sd3", "hidream", "qwen_image", "qwen_edit", "krea2"]);
|
||||
// krea2 deliberately excluded: native cond-mask regional doesn't localize on
|
||||
// Flux-based DiT (GPU-tested — regions smear onto the subject).
|
||||
const NATIVE_REGIONAL_ARCH = new Set(["flux", "flux2", "sd3", "hidream", "qwen_image", "qwen_edit"]);
|
||||
if (NATIVE_REGIONAL_ARCH.has(arch)) {
|
||||
const rc = LG.createNode("PromptChain_RegionalConditioning");
|
||||
if (!rc) return false;
|
||||
@@ -3108,17 +3121,12 @@ export async function showModelModal(pcNode, modelInfo, anchorEl, { tab = null }
|
||||
],
|
||||
});
|
||||
}
|
||||
// Krea 2 has no published ControlNet (and ComfyUI's krea2 model class exposes
|
||||
// no control hook), but native cond-mask regional works — PromptChain_RegionalConditioning
|
||||
// is model-agnostic. Offer Regional only.
|
||||
if (arch === "krea2") {
|
||||
items.push({
|
||||
label: "ControlNet",
|
||||
children: [
|
||||
{ label: "Regional", value: "ControlNet:regional" },
|
||||
],
|
||||
});
|
||||
}
|
||||
// Krea 2 has no working regional path: it's a Flux.1-dev finetune, and
|
||||
// Flux/DiT models don't honour ComfyUI's native cond-area masks (the only
|
||||
// regional engine available to krea2). GPU-tested — "a cat"/"a dog" boxes
|
||||
// smeared onto the main subject instead of separating. A real krea2
|
||||
// regional would need a Flux attention-couple node (like ZImageRegionalCouple),
|
||||
// which doesn't exist; until then Regional is intentionally NOT offered.
|
||||
// Z-Image ControlNet (DiffSynth model-patch path; core nodes + aux
|
||||
// preprocessors). The control model is family-specific: Turbo's is
|
||||
// step-distilled, base's isn't, and they're NOT cross-compatible — so a
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// $region highlight — colors `$name { ... }` markers (regional-conditioning blocks)
|
||||
// so it's visually obvious they pin a section to a mannequin. Self-contained CM6
|
||||
// extension, no node/app imports. Regions don't nest, so the matching close brace
|
||||
// is simply the next `}` after the opener.
|
||||
// extension, no node/app imports. The close brace is matched by DEPTH so a
|
||||
// wildcard/alternation brace ({a|b}) inside a body isn't mistaken for the region
|
||||
// close, and the opener allows any \w+ name to match the binding/compiler rule.
|
||||
|
||||
export function regionHighlightExtension(CM) {
|
||||
const nameDeco = CM.Decoration.mark({ class: "pcr-region-name" });
|
||||
const braceDeco = CM.Decoration.mark({ class: "pcr-region-brace" });
|
||||
const OPEN = /\$[A-Za-z]\w*\s*\{/g;
|
||||
const OPEN = /\$\w+\s*\{/g;
|
||||
|
||||
const build = (view) => {
|
||||
const ranges = [];
|
||||
@@ -14,12 +15,22 @@ export function regionHighlightExtension(CM) {
|
||||
let m;
|
||||
OPEN.lastIndex = 0;
|
||||
while ((m = OPEN.exec(text)) !== null) {
|
||||
const nameLen = m[0].match(/^\$[A-Za-z]\w*/)[0].length;
|
||||
const nameLen = m[0].match(/^\$\w+/)[0].length;
|
||||
ranges.push(nameDeco.range(m.index, m.index + nameLen)); // $name
|
||||
const open = m.index + m[0].length - 1;
|
||||
ranges.push(braceDeco.range(open, open + 1)); // {
|
||||
const close = text.indexOf("}", open + 1);
|
||||
if (close !== -1) ranges.push(braceDeco.range(close, close + 1)); // }
|
||||
// Depth-track to the matching close (mirrors the binding parser) so nested
|
||||
// wildcard braces in the body don't steal the region-close decoration.
|
||||
let depth = 1, i = open + 1;
|
||||
for (; i < text.length && depth > 0; i++) {
|
||||
const c = text[i];
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
}
|
||||
if (depth === 0) {
|
||||
ranges.push(braceDeco.range(i - 1, i)); // }
|
||||
OPEN.lastIndex = i; // resume past this region; its body never holds another opener
|
||||
}
|
||||
}
|
||||
ranges.sort((a, b) => a.from - b.from);
|
||||
return CM.Decoration.set(ranges, true);
|
||||
|
||||
@@ -3640,6 +3640,7 @@
|
||||
/* {@html} content — needs :global */
|
||||
.pcr-output-panel-label-pos { color: #4fc3f7; font-weight: 600; }
|
||||
.pcr-output-panel-label-neg { color: #e74c3c; font-weight: 600; }
|
||||
.pcr-output-panel-label-region { color: #ffb74d; font-weight: 600; }
|
||||
.pcr-output-panel-placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
|
||||
@@ -4,7 +4,7 @@ var __typeError = (msg) => {
|
||||
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
|
||||
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
|
||||
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
|
||||
var _mode, _switchIndex, _locked, _disabled, _collapsed, _connectedCount, _hasLabels, _switchLabel, _iterateCurrent, _iterateTotal, _iterateCycle, _switchOptions, _outputPanelOpen, _imagePanelVisible, _aiAssistantOpen, _posePanelVisible, _regionPanelVisible, _hasPoseStudio, _hasRegionBox, _compiledOutput, _compiledNegOutput, _imageUrl, _previewUrl, _progress, _isGenerating, _rollSelected;
|
||||
var _mode, _switchIndex, _locked, _disabled, _collapsed, _connectedCount, _hasLabels, _switchLabel, _iterateCurrent, _iterateTotal, _iterateCycle, _switchOptions, _outputPanelOpen, _imagePanelVisible, _aiAssistantOpen, _posePanelVisible, _regionPanelVisible, _hasPoseStudio, _hasRegionBox, _compiledOutput, _compiledNegOutput, _compiledRegions, _imageUrl, _previewUrl, _progress, _isGenerating, _rollSelected;
|
||||
import { p as push, o as bind_this, k as append, l as pop, A as from_html, d as delegate, a as prop, g as get, j as delegated, m as user_derived, s as state, c as proxy, u as user_effect, i as if_block, f as sibling, r as event, e as set, C as tick, n as child, D as comment, v as first_child, w as each, t as template_effect, y as set_text, h as set_class, x as set_attribute, Q as clsx, z as index, R as text, E as untrack, M as unmount, L as mount } from "./disclose-version-et9wt-4m.js";
|
||||
import { u as useApi, p as provideApi } from "./api-context-BNqvELYR.js";
|
||||
import { M as Menubar } from "./Menubar-CMm15KuK.js";
|
||||
@@ -34,7 +34,13 @@ function EditorPane($$anchor, $$props) {
|
||||
var root$c = from_html(`<div class="pcr-output-panel-content pcr-scrollable svelte-qbaa69" tabindex="0"></div>`);
|
||||
function PromptOutput($$anchor, $$props) {
|
||||
push($$props, true);
|
||||
let compiledOutput = prop($$props, "compiledOutput", 3, ""), compiledNegOutput = prop($$props, "compiledNegOutput", 3, "");
|
||||
let compiledOutput = prop($$props, "compiledOutput", 3, ""), compiledNegOutput = prop($$props, "compiledNegOutput", 3, ""), compiledRegions = prop(
|
||||
$$props,
|
||||
"compiledRegions",
|
||||
3,
|
||||
""
|
||||
// JSON {global, regions:[{name,text}], negative} sent with the compile
|
||||
);
|
||||
const _HTML_ESCAPES = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
@@ -45,10 +51,39 @@ function PromptOutput($$anchor, $$props) {
|
||||
function escapeHtml(text2) {
|
||||
return String(text2).replace(/[&<>"']/g, (c) => _HTML_ESCAPES[c]);
|
||||
}
|
||||
let regional = user_derived(() => {
|
||||
if (!compiledRegions()) return null;
|
||||
try {
|
||||
const o = JSON.parse(compiledRegions());
|
||||
return o && Array.isArray(o.regions) && o.regions.length ? o : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
let html$1 = user_derived(() => {
|
||||
if (!compiledOutput() && !compiledNegOutput()) {
|
||||
if (!compiledOutput() && !compiledNegOutput() && !get(regional)) {
|
||||
return '<span class="pcr-output-panel-placeholder">Queue workflow to see prompt output</span>';
|
||||
}
|
||||
if (get(regional)) {
|
||||
const regEmpty = '<span class="pcr-output-panel-placeholder">(empty → uses global)</span>';
|
||||
let result2 = "";
|
||||
for (const reg of get(regional).regions) {
|
||||
const body = (reg.text || "").trim();
|
||||
result2 += `<span class="pcr-output-panel-label-region">$${escapeHtml(reg.name || "")}</span>
|
||||
${body ? escapeHtml(body) : regEmpty}
|
||||
|
||||
`;
|
||||
}
|
||||
const g = (get(regional).global || "").trim();
|
||||
result2 += `<span class="pcr-output-panel-label-pos">Global:</span>
|
||||
${g ? escapeHtml(g) : '<span class="pcr-output-panel-placeholder">(none)</span>'}`;
|
||||
const neg = get(regional).negative || compiledNegOutput();
|
||||
if (neg) result2 += `
|
||||
|
||||
<span class="pcr-output-panel-label-neg">Negative:</span>
|
||||
${escapeHtml(neg)}`;
|
||||
return result2;
|
||||
}
|
||||
let result = `<span class="pcr-output-panel-label-pos">Positive:</span>
|
||||
${escapeHtml(compiledOutput())}`;
|
||||
if (compiledNegOutput()) {
|
||||
@@ -516,6 +551,9 @@ function OutputPanel($$anchor, $$props) {
|
||||
},
|
||||
get compiledNegOutput() {
|
||||
return $$props.shared.compiledNegOutput;
|
||||
},
|
||||
get compiledRegions() {
|
||||
return $$props.shared.compiledRegions;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -3917,6 +3955,7 @@ class SharedState {
|
||||
__privateAdd(this, _hasRegionBox, state(false));
|
||||
__privateAdd(this, _compiledOutput, state(""));
|
||||
__privateAdd(this, _compiledNegOutput, state(""));
|
||||
__privateAdd(this, _compiledRegions, state(""));
|
||||
__privateAdd(this, _imageUrl, state(null));
|
||||
__privateAdd(this, _previewUrl, state(null));
|
||||
__privateAdd(this, _progress, state(null));
|
||||
@@ -3939,6 +3978,7 @@ class SharedState {
|
||||
this.regionPanelVisible = !!p.pcrRegionPanel;
|
||||
this.compiledOutput = p.pcrCompiledOutput || "";
|
||||
this.compiledNegOutput = p.pcrCompiledNegOutput || "";
|
||||
this.compiledRegions = p.pcrCompiledRegions || "";
|
||||
}
|
||||
get mode() {
|
||||
return get(__privateGet(this, _mode));
|
||||
@@ -4066,6 +4106,12 @@ class SharedState {
|
||||
set compiledNegOutput(value) {
|
||||
set(__privateGet(this, _compiledNegOutput), value, true);
|
||||
}
|
||||
get compiledRegions() {
|
||||
return get(__privateGet(this, _compiledRegions));
|
||||
}
|
||||
set compiledRegions(value) {
|
||||
set(__privateGet(this, _compiledRegions), value, true);
|
||||
}
|
||||
get imageUrl() {
|
||||
return get(__privateGet(this, _imageUrl));
|
||||
}
|
||||
@@ -4118,6 +4164,7 @@ _hasPoseStudio = new WeakMap();
|
||||
_hasRegionBox = new WeakMap();
|
||||
_compiledOutput = new WeakMap();
|
||||
_compiledNegOutput = new WeakMap();
|
||||
_compiledRegions = new WeakMap();
|
||||
_imageUrl = new WeakMap();
|
||||
_previewUrl = new WeakMap();
|
||||
_progress = new WeakMap();
|
||||
|
||||
@@ -1701,12 +1701,14 @@ app.registerExtension({
|
||||
if (!this.properties) this.properties = {};
|
||||
this.properties.pcrCompiledOutput = message.text[0];
|
||||
this.properties.pcrCompiledNegOutput = message.neg_text?.[0] || "";
|
||||
this.properties.pcrCompiledRegions = message.regions?.[0] || "";
|
||||
this._pcrOutputText = message.text[0];
|
||||
this._pcrNegOutputText = message.neg_text?.[0] || "";
|
||||
this._pcrRegionsText = message.regions?.[0] || "";
|
||||
if (shared) {
|
||||
shared.compiledOutput = message.text[0];
|
||||
shared.compiledNegOutput = message.neg_text?.[0] || "";
|
||||
shared.compiledRegions = message.regions?.[0] || "";
|
||||
}
|
||||
updateInputLabels(this);
|
||||
requestAnimationFrame(() => attachSlotClickHandlers(this));
|
||||
|
||||
+58
-3
@@ -1160,6 +1160,30 @@ function deleteSupersededSet(previousRef, newBase) {
|
||||
}).catch((e) => console.warn("[PoseStudio] superseded-set delete failed", e));
|
||||
}
|
||||
|
||||
// Pin a content-addressed capture set so a later re-pose's superseded-delete
|
||||
// can't remove it (the delete route honours <base>.keep). Fire-and-forget.
|
||||
function pinCaptureSet(ref) {
|
||||
const name = ref?.split("/").pop()?.replace(/ \[\w+\]$/, "") || "";
|
||||
const base = name.endsWith(".png") ? name.slice(0, -4) : null;
|
||||
if (!base || !/^promptchain_pose_[0-9a-f]{12}$/.test(base)) return;
|
||||
api.fetchApi("/promptchain/pose-files/pin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ base }),
|
||||
}).catch((e) => console.warn("[PoseStudio] pin failed", e));
|
||||
}
|
||||
|
||||
// Before a prompt is enqueued, pin every live Poser's current control map so a
|
||||
// re-pose while that render waits in a busy queue can't GC the set it
|
||||
// references (execute() also pins, but only once the render actually starts).
|
||||
function pinLivePoserSets() {
|
||||
for (const n of poseRegistry.all()) {
|
||||
if (!n._pcrAlive) continue;
|
||||
const ref = n.widgets?.find((w) => w.name === "control_map")?.value;
|
||||
if (ref) pinCaptureSet(ref);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureAndUpload(node) {
|
||||
const ps = node._pcrPose;
|
||||
if (!ps || !node._pcrAlive) return; // bail if torn down (renderer may be disposed)
|
||||
@@ -1885,6 +1909,16 @@ async function mountViewport(node, container) {
|
||||
return;
|
||||
}
|
||||
if (!node._pcrAlive) return; // node was removed during the async load
|
||||
// Re-mount safety: drop a prior mount's global capture listeners before
|
||||
// building a new scene — node._pcrPose is overwritten at the end of this
|
||||
// function, so without this a re-mount would leak the previous keydown/
|
||||
// pointerdown/contextmenu handlers (a duplicate global Ctrl+Z over a dead scene).
|
||||
const _priorPose = node._pcrPose;
|
||||
if (_priorPose) {
|
||||
if (_priorPose.onKey) window.removeEventListener("keydown", _priorPose.onKey, true);
|
||||
if (_priorPose.onWinPointerDown) window.removeEventListener("pointerdown", _priorPose.onWinPointerDown, true);
|
||||
if (_priorPose.onContextMenu) window.removeEventListener("contextmenu", _priorPose.onContextMenu, true);
|
||||
}
|
||||
const { THREE, OrbitControls, TransformControls, GLTFLoader, OBJLoader } = lib;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
@@ -3398,6 +3432,7 @@ async function mountViewport(node, container) {
|
||||
// stay depth dressing). Uses the live materials so skinning/pose is preserved;
|
||||
// isolates by visibility, so it survives overlaps a screen-space split can't.
|
||||
let maskTarget = null;
|
||||
let maskOverrideMat = null; // shared silhouette override — built once, disposed in disposeCapture (not per capture)
|
||||
let captureBusy = false; // blocks renderFrame from clobbering renderer state mid-capture
|
||||
const captureRegionMasks = async () => {
|
||||
// Mask-row order = entity order: figures first (so heads[] / detailer face
|
||||
@@ -3464,7 +3499,8 @@ async function mountViewport(node, container) {
|
||||
// with the normal lit materials bakes shading into the mask (~0.6 gray),
|
||||
// which downstream consumers multiply by — the regional detailer's
|
||||
// re-render strength was silently capped at that gray level.
|
||||
scene.overrideMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
if (!maskOverrideMat) maskOverrideMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
scene.overrideMaterial = maskOverrideMat;
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const ent = entities[i];
|
||||
figures.forEach((f) => { f.root.visible = ent.kind === "figure" && f === ent.o; });
|
||||
@@ -3506,7 +3542,7 @@ async function mountViewport(node, container) {
|
||||
return out;
|
||||
};
|
||||
const disposeCapture = () => {
|
||||
captureTarget?.dispose(); maskTarget?.dispose();
|
||||
captureTarget?.dispose(); maskTarget?.dispose(); maskOverrideMat?.dispose();
|
||||
depthOutTarget?.dispose(); depthLinearGeo.dispose(); depthLinearMat.dispose();
|
||||
outlineTarget?.dispose(); outlineQuadGeo.dispose();
|
||||
outlineQuadMat.dispose(); outlineDepthMat.dispose(); outlineSelMat.dispose(); outlineActMat.dispose();
|
||||
@@ -5943,7 +5979,12 @@ async function mountViewport(node, container) {
|
||||
const MAX_FOLD = 0.95; // rad (~54°) max bend at any single joint — generous enough for tight corkscrews, tight enough to stop the inside-out kink / tangent-flip singularity
|
||||
const COS_HALF_FOLD = Math.cos(MAX_FOLD / 2); // min next-nearest distance = (len[i]+len[i+1]) * this
|
||||
const chainRopeStep = () => {
|
||||
if (!chainDrag.active || !chainDrag.prop || !propsGroup.children.includes(chainDrag.prop)) { chainDrag.settling = false; return; }
|
||||
// Liveness guard FIRST: a node torn down mid chain-drag (Ctrl+Z rebuild,
|
||||
// workflow switch) never fires pointerup, so chainDrag.active stays true and
|
||||
// the prop stays in propsGroup — without this the rAF loop re-arms forever on
|
||||
// disposed three.js objects. No cancelAnimationFrame exists, so the loop must
|
||||
// self-terminate on the teardown flag.
|
||||
if (!node._pcrAlive || !chainDrag.active || !chainDrag.prop || !propsGroup.children.includes(chainDrag.prop)) { chainDrag.settling = false; return; }
|
||||
const prop = chainDrag.prop, rope = chainDrag.rope, lens = chainDrag.ropeLens;
|
||||
const n = rope.length - 1, pin = chainDrag.pinIdx, grab = chainDrag.driveCount;
|
||||
const pinTarget = chainPinTarget(prop, _ropeTmp).clone();
|
||||
@@ -11134,6 +11175,20 @@ export async function mountDetachedPoser(parentEl, { width = 832, height = 1216,
|
||||
|
||||
app.registerExtension({
|
||||
name: "PromptChain.PoseStudio",
|
||||
setup() {
|
||||
// Pin-on-enqueue: wrap the queue action once so a busy-queue re-pose can't
|
||||
// GC a capture set whose render is still pending (execute() pins only when
|
||||
// the render actually starts). Event-driven — reacts to the queue action,
|
||||
// never polls — and always delegates to the original.
|
||||
if (!app._pcrPoseQueueHook && typeof app.queuePrompt === "function") {
|
||||
app._pcrPoseQueueHook = true;
|
||||
const origQueue = app.queuePrompt.bind(app);
|
||||
app.queuePrompt = function (...args) {
|
||||
try { pinLivePoserSets(); } catch (e) { console.warn("[PoseStudio] pin-on-queue failed", e); }
|
||||
return origQueue(...args);
|
||||
};
|
||||
}
|
||||
},
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== NODE_TYPE) return;
|
||||
setupPoseStudio(node);
|
||||
|
||||
+6
-2
@@ -328,7 +328,9 @@ function setupRegionBox(node) {
|
||||
}
|
||||
function uniqueName() {
|
||||
let n = 1;
|
||||
const have = new Set(boxes.map((b) => b.name));
|
||||
// Case-insensitive: the backend lowercases entity names, so 'Hero'/'hero'
|
||||
// collide to one mask — match that here so names stay unambiguous.
|
||||
const have = new Set(boxes.map((b) => b.name.toLowerCase()));
|
||||
while (have.has("region" + n)) n++;
|
||||
return "region" + n;
|
||||
}
|
||||
@@ -541,7 +543,9 @@ function setupRegionBox(node) {
|
||||
if (i < 0) return;
|
||||
const next = sanitize(raw);
|
||||
if (!next || next === boxes[i].name) { renderList(); return; }
|
||||
if (boxes.some((b, j) => j !== i && b.name === next)) { renderList(); return; } // keep unique
|
||||
// Case-insensitive uniqueness — the backend lowercases names, so 'Hero' and
|
||||
// 'hero' would otherwise pass here then collide to one mask row.
|
||||
if (boxes.some((b, j) => j !== i && b.name.toLowerCase() === next.toLowerCase())) { renderList(); return; }
|
||||
const old = boxes[i].name;
|
||||
boxes[i].name = next;
|
||||
persist();
|
||||
|
||||
@@ -71,15 +71,17 @@ def patch_model_regional(model, base_cond, regions, base_weight: float):
|
||||
weights = torch.stack(weight_maps, dim=0) # [num_conds, 1, H, W]
|
||||
weights = weights / weights.sum(dim=0, keepdim=True) # per-pixel sum == 1
|
||||
|
||||
state = {"device": None}
|
||||
state = {"device": None, "dtype": None}
|
||||
|
||||
def _to(device, dtype):
|
||||
if state["device"] == device:
|
||||
if state["device"] == device and state["dtype"] == dtype:
|
||||
return
|
||||
state["conds"] = [c.to(device, dtype=dtype) for c in cond_embeds]
|
||||
# collapse the singleton channel so the mask is [num_conds, H, W]
|
||||
state["weights"] = weights.squeeze(1).to(device, dtype=dtype)
|
||||
state["device"] = device
|
||||
state["dtype"] = dtype
|
||||
state["region_kv_lcm"] = None # invalidate cached region K/V (rebuilt below)
|
||||
|
||||
def attn2_patch(q, k, v, extra_options):
|
||||
_to(q.device, q.dtype)
|
||||
@@ -105,11 +107,16 @@ def patch_model_regional(model, base_cond, regions, base_weight: float):
|
||||
full_lcm = full_lcm * t // math.gcd(full_lcm, t)
|
||||
|
||||
# text-embedding stack for one image: each region prompt repeated along
|
||||
# the token axis to the common LCM length, then over the batch.
|
||||
region_kv = torch.cat(
|
||||
[c.repeat(1, full_lcm // token_counts[i], 1) for i, c in enumerate(conds)],
|
||||
dim=0,
|
||||
) # [num_conds, full_lcm, ctx]
|
||||
# the token axis to the common LCM length. Constant across every
|
||||
# attention layer and step of a render, so build once per (device, dtype,
|
||||
# lcm) and reuse — only the per-batch interleave below varies.
|
||||
if state.get("region_kv_lcm") != full_lcm:
|
||||
state["region_kv"] = torch.cat(
|
||||
[c.repeat(1, full_lcm // token_counts[i], 1) for i, c in enumerate(conds)],
|
||||
dim=0,
|
||||
) # [num_conds, full_lcm, ctx]
|
||||
state["region_kv_lcm"] = full_lcm
|
||||
region_kv = state["region_kv"]
|
||||
|
||||
out_q, out_kv = [], []
|
||||
for i, kind in enumerate(chunk_kinds):
|
||||
@@ -241,10 +248,17 @@ class AttentionCoupleNode(io.ComfyNode):
|
||||
for n, (r, idx) in enumerate(zip(region_list, region_figure_indices(region_list, pose))):
|
||||
if orphans[n]:
|
||||
continue
|
||||
# Empty $block → fall back to the global prompt inside that figure
|
||||
# (matches RegionalConditioning / ZImageRegionalCouple). Otherwise the
|
||||
# empty CLIP encode competes with the global at weight 1.0 and starves
|
||||
# the figure of ~2/3 of its guidance.
|
||||
text = (r.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
idx = min(max(idx, 0), num_masks - 1)
|
||||
mask = _dilate(masks[idx:idx + 1], mask_dilation)
|
||||
regions_for_patch.append(
|
||||
{"cond": encode(r.get("text", "")), "mask": mask, "weight": 1.0})
|
||||
{"cond": encode(text), "mask": mask, "weight": 1.0})
|
||||
|
||||
# Every region was an orphan -> no regional split; just the global cond.
|
||||
if not regions_for_patch:
|
||||
|
||||
@@ -279,8 +279,8 @@ class ZImageRegionalCoupleNode(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, model, clip, regions: str = "", masks: torch.Tensor = None,
|
||||
base_weight: float = 1.0, mask_dilation: int = 22,
|
||||
region_strength: float = 8.0, figure_isolation: float = 6.0,
|
||||
end_percent: float = 0.55, pose: str = "") -> io.NodeOutput:
|
||||
region_strength: float = 1.0, figure_isolation: float = 4.0,
|
||||
end_percent: float = 0.7, pose: str = "") -> io.NodeOutput:
|
||||
|
||||
def encode_raw(text):
|
||||
tokens = clip.tokenize(text or "")
|
||||
|
||||
+151
-88
@@ -9,53 +9,121 @@ from comfy_api.latest import io
|
||||
logger = logging.getLogger("promptchain.ideogram_caption")
|
||||
|
||||
|
||||
# Non-photographic media. If the prompt names one, the style_description must use
|
||||
# the art_style path (aesthetics,lighting,medium,art_style) instead of the photo
|
||||
# path (aesthetics,lighting,photo,medium) — Ideogram's caption verifier requires
|
||||
# exactly one of photo|art_style.
|
||||
# Most specific first so e.g. "watercolor illustration" resolves to "watercolor",
|
||||
# not the generic "illustration"/"render" which sit last as catch-alls.
|
||||
_ART_MEDIA = ("watercolor", "oil painting", "3d render", "concept art", "pixel art",
|
||||
"line art", "cel shaded", "digital art", "gouache", "acrylic",
|
||||
"woodcut", "lithograph", "manga", "anime", "comic", "cartoon",
|
||||
"painting", "drawing", "sketch", "vector", "illustration", "render")
|
||||
|
||||
|
||||
def _medium_word(text: str) -> str:
|
||||
"""The rendering medium named in the prompt, or 'photograph' by default."""
|
||||
t = (text or "").lower()
|
||||
return next((m for m in _ART_MEDIA if m in t), "photograph")
|
||||
|
||||
|
||||
def _clean(text: str) -> str:
|
||||
"""Tidy the lazily-joined global prompt: a region/line join can leave 'focus., x'
|
||||
or doubled commas/spaces. Cosmetic, but it lands verbatim in background/aesthetics."""
|
||||
t = re.sub(r"\s*\.\s*,", ", ", text or "")
|
||||
t = re.sub(r"\s*,\s*,", ", ", t)
|
||||
return re.sub(r"\s+", " ", t).strip(" ,")
|
||||
|
||||
|
||||
def _first_clause(text: str, max_words: int) -> str:
|
||||
"""First sentence/line of `text`, word-capped — for a terse style/summary line."""
|
||||
t = (text or "").strip()
|
||||
for sep in (". ", ".\n", "\n"):
|
||||
if sep in t:
|
||||
t = t.split(sep, 1)[0]
|
||||
break
|
||||
words = t.strip().rstrip(".").split()
|
||||
return " ".join(words[:max_words])
|
||||
|
||||
|
||||
def _short_subject(desc: str) -> str:
|
||||
"""A subject's first comma-clause, word-capped — its brief mention in the HLD
|
||||
(the full description still lives in the element's `desc`)."""
|
||||
first = (desc or "").strip().split(",")[0].strip()
|
||||
return " ".join(first.split()[:8])
|
||||
|
||||
|
||||
def _human_join(items: list[str]) -> str:
|
||||
items = [i for i in items if i]
|
||||
if len(items) <= 1:
|
||||
return items[0] if items else ""
|
||||
if len(items) == 2:
|
||||
return f"{items[0]} and {items[1]}"
|
||||
return ", ".join(items[:-1]) + f", and {items[-1]}"
|
||||
|
||||
|
||||
def _style_block(text: str) -> dict:
|
||||
"""An Ideogram `style_description` object in the verifier's strict key order.
|
||||
|
||||
Photographic by default; switches to the art_style path when the prompt names
|
||||
a non-photo medium. Omitting style_description samples outside the model's
|
||||
training distribution and (per Ideogram) raises the safety false-positive
|
||||
rate, so we always emit a conforming one; its shared aesthetics/lighting also
|
||||
bind multiple regions into a single coherent scene instead of a collage."""
|
||||
medium = _medium_word(text)
|
||||
aesthetics = _first_clause(text, 16) or "natural and true to life"
|
||||
if medium != "photograph":
|
||||
return {"aesthetics": aesthetics, "lighting": "soft, even lighting",
|
||||
"medium": "digital art", "art_style": medium}
|
||||
return {"aesthetics": aesthetics, "lighting": "natural light",
|
||||
"photo": "sharp focus, realistic detail", "medium": "photograph"}
|
||||
|
||||
|
||||
def _caption(text: str) -> str:
|
||||
"""Wrap a plain PromptChain prompt in a COMPLETE Ideogram JSON caption.
|
||||
"""Wrap a plain (region-less) PromptChain prompt in a COMPLETE Ideogram caption.
|
||||
|
||||
Ideogram 4 was trained on structured JSON captions and its built-in safety
|
||||
*false-positive* rate is far higher for non-conforming prompts (official:
|
||||
"false positive rates ... higher for non-json like prompts"). Empirically,
|
||||
on flagged content (e.g. a swimsuit/rear-pose character): plain text AND a
|
||||
JSON wrapper with an EMPTY `elements` array both block ~100% across seeds,
|
||||
while a COMPLETE caption — high_level_description + background + at least one
|
||||
real `element` — passes ~100% regardless of where the content sits (HLD,
|
||||
background, or element). So the whole prompt goes into one element's `desc`
|
||||
under a benign summary: that completeness is what makes the model read it as
|
||||
a conforming caption rather than falling back to the non-JSON safety path.
|
||||
"""
|
||||
t = (text or "").strip()
|
||||
# The element bbox must be an INSET subregion: empirically a full-frame
|
||||
# [0,0,1000,1000] bbox AND omitting bbox both block ~100% on flagged content,
|
||||
# while an inset region passes (the model reads it as "an object placed in a
|
||||
# scene" rather than "the whole image IS this object"). Centred, with margins
|
||||
# on all sides; the desc still drives the full composition.
|
||||
*false-positive* rate is higher for non-conforming prompts (official:
|
||||
"false positive rates ... higher for non-json like prompts"). The single
|
||||
documented lever to reduce refusals is a conforming caption, so we emit all
|
||||
three top-level keys (high_level_description, style_description,
|
||||
compositional_deconstruction) with one bbox'd element — a boxless element
|
||||
re-trips the safety gray screen (ideogram4 issue #13)."""
|
||||
t = _clean(text)
|
||||
hld = (_first_clause(t, 40) or "A high-quality, detailed image.").rstrip(".") + "."
|
||||
return json.dumps({
|
||||
"high_level_description": "A high-quality, detailed image.",
|
||||
"high_level_description": hld,
|
||||
"style_description": _style_block(t),
|
||||
"compositional_deconstruction": {
|
||||
"background": "a fitting background that suits the subject",
|
||||
"elements": [{"type": "obj", "bbox": [60, 250, 990, 770], "desc": t}],
|
||||
"background": "a setting that suits the subject",
|
||||
"elements": [{"type": "obj", "bbox": list(_FULL_FRAME_INSET),
|
||||
"desc": t or "the subject"}],
|
||||
},
|
||||
}, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
# Ideogram bbox is [y_min, x_min, y_max, x_max] on a 0-1000 grid, top-left origin.
|
||||
_FULL_FRAME_INSET = [60, 250, 990, 770] # the proven single-element box
|
||||
# Ideogram bbox is [y_min, x_min, y_max, x_max] on a 0-1000 grid, top-left origin
|
||||
# (verified against ideogram4's shipped caption_verifier.py). Used as the fallback
|
||||
# box for a region the user gave no Region Box — an element with NO bbox re-trips
|
||||
# the safety gray screen (ideogram4 issue #13), so every element gets one.
|
||||
_FULL_FRAME_INSET = [60, 250, 990, 770]
|
||||
|
||||
|
||||
def _inset_bbox(bbox) -> list[int]:
|
||||
"""Clamp a box to 0-1000 and guarantee it is an INSET (never full-frame).
|
||||
def _clamp_bbox(bbox) -> list[int]:
|
||||
"""Clamp a user-drawn box to the 0-1000 grid (y-first, y0<y1 / x0<x1).
|
||||
|
||||
A full-frame element re-trips the safety false-positive (the model reads it
|
||||
as "the whole image IS this object"). A user-drawn box is normally inset
|
||||
already; this only rescues an edge-to-edge box."""
|
||||
A drawn box is kept AS DRAWN — Ideogram bboxes are soft placement hints that
|
||||
are meant to overlap and nest (the canonical multi-subject examples do), so
|
||||
forcing a large box to a fixed inset only mis-places the subject. Only a
|
||||
degenerate (zero-area) box falls back to the centred box, purely to keep the
|
||||
safety-required bbox presence."""
|
||||
try:
|
||||
y0, x0, y1, x1 = (int(round(v)) for v in bbox)
|
||||
except (TypeError, ValueError):
|
||||
return list(_FULL_FRAME_INSET)
|
||||
y0, y1 = sorted((max(0, min(1000, y0)), max(0, min(1000, y1))))
|
||||
x0, x1 = sorted((max(0, min(1000, x0)), max(0, min(1000, x1))))
|
||||
# Degenerate or effectively full-frame -> fall back to the proven inset.
|
||||
if x1 - x0 < 10 or y1 - y0 < 10 or (x1 - x0 >= 990 and y1 - y0 >= 990):
|
||||
if x1 - x0 < 10 or y1 - y0 < 10:
|
||||
return list(_FULL_FRAME_INSET)
|
||||
return [y0, x0, y1, x1]
|
||||
|
||||
@@ -74,14 +142,17 @@ def _load(obj):
|
||||
# A leading quoted literal ('...' or "...") in a `$name{}` region's BODY marks a
|
||||
# TEXT element — Ideogram renders that exact string (its headline feature). This
|
||||
# is a property of a real scope (the region body), never of a comment.
|
||||
_LITERAL_RE = re.compile(r"""^\s*['"](.+?)['"]\s*(.*)$""", re.DOTALL)
|
||||
# The closing quote must be the SAME character that opened (\1 backreference),
|
||||
# so a double-quoted literal containing an apostrophe ("Mom's Diner") isn't
|
||||
# truncated at the apostrophe.
|
||||
_LITERAL_RE = re.compile(r"""^\s*(['"])(.+?)\1\s*(.*)$""", re.DOTALL)
|
||||
|
||||
|
||||
def _split_literal(s: str):
|
||||
"""('LITERAL', rest-as-desc) if s starts with a quoted literal, else (None, s)."""
|
||||
m = _LITERAL_RE.match(s or "")
|
||||
if m:
|
||||
return m.group(1).strip(), m.group(2).strip()
|
||||
return m.group(2).strip(), m.group(3).strip()
|
||||
return None, (s or "").strip()
|
||||
|
||||
|
||||
@@ -104,36 +175,33 @@ def _mk_text(e: dict) -> dict:
|
||||
return o
|
||||
|
||||
|
||||
def _pos_word(bbox) -> str:
|
||||
"""A rough placement word from a 0-1000 [y0,x0,y1,x1] box, for the HLD."""
|
||||
if not (isinstance(bbox, (list, tuple)) and len(bbox) == 4):
|
||||
return ""
|
||||
xc = (bbox[1] + bbox[3]) / 2.0
|
||||
yc = (bbox[0] + bbox[2]) / 2.0
|
||||
h = "left" if xc < 400 else "right" if xc > 600 else ""
|
||||
v = "top" if yc < 350 else "bottom" if yc > 650 else ""
|
||||
if h and v:
|
||||
return f"{v} {h}"
|
||||
return h or v or "centre"
|
||||
|
||||
|
||||
def _build_caption(prompt_text: str, region_list: list, box_by_name: dict,
|
||||
background_text: str = "") -> str | None:
|
||||
"""Build a conforming Ideogram caption from the prompt text + `$name{}` regions.
|
||||
|
||||
Comments are NOT parsed — `prompt_text` is the already comment-stripped prompt
|
||||
(minus the regions). Structure comes ONLY from real scopes: the reserved
|
||||
`$background{ ... }` region -> the `background` field; each other `$name{}`
|
||||
region -> one element placed at its drawn box (bbox optional, matched by name);
|
||||
a region whose body starts with a quoted literal `"..."` -> a `type:"text"`
|
||||
element (Ideogram renders that exact string). The remaining prompt text ->
|
||||
high_level_description. A complete caption (HLD + background + >=1 INSET-bbox
|
||||
element) keeps the safety bypass, so we anchor one element (preferring a
|
||||
subject) with the proven inset when no drawn box supplied one. Returns None
|
||||
when there's nothing to build.
|
||||
(minus the regions). Structure maps onto Ideogram's real schema (three keys in
|
||||
order: high_level_description, style_description, compositional_deconstruction):
|
||||
|
||||
- each non-reserved `$name{}` region -> ONE element placed at its drawn box
|
||||
(kept as drawn; bbox optional, matched by name). A region body that starts
|
||||
with a quoted literal `"..."` -> a `type:"text"` element.
|
||||
- the SCENE goes in `background`: the reserved `$background{ ... }` region if
|
||||
present, else the leftover global prompt. Ideogram composes the subjects
|
||||
INTO the background — without a real one it stages them as separate framed
|
||||
photos (the collage failure), so we never emit a vague placeholder.
|
||||
- `high_level_description` is a TERSE summary that names the subjects in prose
|
||||
(the per-subject detail stays in each element's `desc`, not duplicated into
|
||||
the HLD — over-stuffing the HLD over-weights the dominant subject and
|
||||
invites a duplicate in the other box).
|
||||
- `style_description` is always emitted; its shared style binds the regions
|
||||
into one image and lowers the safety false-positive rate.
|
||||
|
||||
Returns None when there's nothing to build.
|
||||
"""
|
||||
raw_obj: list[dict] = []
|
||||
raw_text: list[dict] = []
|
||||
subjects: list[str] = []
|
||||
for r in region_list:
|
||||
name = str(r.get("name", "")).lower()
|
||||
box = box_by_name.get(name)
|
||||
@@ -142,51 +210,46 @@ def _build_caption(prompt_text: str, region_list: list, box_by_name: dict,
|
||||
if box is None:
|
||||
continue # nothing to place; the HLD covers it
|
||||
d = name or "subject" # box drawn but no text: keep the placement
|
||||
bbox = _inset_bbox(box) if box is not None else None
|
||||
bbox = _clamp_bbox(box) if box is not None else None
|
||||
lit, rest = _split_literal(d)
|
||||
e = {"text": lit, "desc": rest} if lit is not None else {"desc": d}
|
||||
if lit is not None:
|
||||
e = {"text": lit, "desc": rest}
|
||||
subjects.append(f'a sign reading "{lit}"')
|
||||
target = raw_text
|
||||
else:
|
||||
e = {"desc": d}
|
||||
subjects.append(_short_subject(d))
|
||||
target = raw_obj
|
||||
if bbox is not None:
|
||||
e["bbox"] = bbox
|
||||
(raw_text if lit is not None else raw_obj).append(e)
|
||||
target.append(e)
|
||||
|
||||
prompt_text = (prompt_text or "").strip()
|
||||
if raw_obj or raw_text:
|
||||
# Ideogram leans HARD on the high_level_description; a STYLE-ONLY HLD lets
|
||||
# it free-compose (e.g. a whole wedding party instead of one woman + one
|
||||
# man). Weave the subjects + their box positions into the summary so the
|
||||
# count and placement actually stick.
|
||||
phrases = []
|
||||
for e in raw_obj + raw_text:
|
||||
d = (e.get("desc") or e.get("text") or "").strip()
|
||||
if not d:
|
||||
continue
|
||||
pos = _pos_word(e.get("bbox"))
|
||||
phrases.append(f"{d} on the {pos}" if pos else d)
|
||||
summary = ", and ".join(phrases)
|
||||
if summary and prompt_text:
|
||||
hld = f"{prompt_text.rstrip(' .')}, showing {summary}."
|
||||
elif summary:
|
||||
hld = summary[:1].upper() + summary[1:] + "."
|
||||
else:
|
||||
hld = prompt_text or "A high-quality, detailed image."
|
||||
else:
|
||||
prompt_text = _clean(prompt_text)
|
||||
if not (raw_obj or raw_text):
|
||||
if not prompt_text:
|
||||
return None
|
||||
raw_obj = [{"desc": prompt_text}] # no regions: the prompt is the subject
|
||||
hld = "A high-quality, detailed image."
|
||||
subjects = []
|
||||
|
||||
# safety: guarantee >=1 element carries an inset bbox (a boxless / full-frame
|
||||
# caption re-trips the safety false-positive). Prefer anchoring a subject.
|
||||
# >=1 element must carry a bbox — a boxless caption re-trips the safety gray
|
||||
# screen (ideogram4 issue #13). Anchor a subject with the centred fallback.
|
||||
if not any("bbox" in e for e in raw_obj + raw_text):
|
||||
(raw_obj[0] if raw_obj else raw_text[0])["bbox"] = list(_FULL_FRAME_INSET)
|
||||
|
||||
elements = [_mk_obj(o) for o in raw_obj] + [_mk_text(t) for t in raw_text]
|
||||
background = (background_text or "").strip() or "a fitting background that suits the subject"
|
||||
if subjects:
|
||||
medium = _medium_word(prompt_text)
|
||||
lead = "An" if medium[:1].lower() in "aeiou" else "A"
|
||||
hld = f"{lead} {medium} of {_human_join(subjects)}."
|
||||
else:
|
||||
hld = (prompt_text or "A high-quality, detailed image.").rstrip(".") + "."
|
||||
|
||||
background = (background_text or "").strip() or prompt_text or "a fitting scene"
|
||||
cap = {
|
||||
"high_level_description": hld,
|
||||
"style_description": _style_block(prompt_text),
|
||||
"compositional_deconstruction": {
|
||||
"background": background,
|
||||
"elements": elements,
|
||||
"elements": [_mk_obj(o) for o in raw_obj] + [_mk_text(t) for t in raw_text],
|
||||
},
|
||||
}
|
||||
return json.dumps(cap, separators=(",", ":"), ensure_ascii=False)
|
||||
@@ -278,8 +341,8 @@ class IdeogramCaptionNode(io.ComfyNode):
|
||||
# Caption from the prompt text + $name{} region scopes only. Comments are
|
||||
# never parsed for structure — they are stripped by the compiler.
|
||||
caption = _build_caption(prompt_text, subjects, box_by_name, background_text)
|
||||
if caption:
|
||||
return io.NodeOutput(caption)
|
||||
if not s:
|
||||
return io.NodeOutput(text)
|
||||
return io.NodeOutput(_caption(text))
|
||||
if not caption:
|
||||
caption = _caption(text) if s else text
|
||||
# Ground truth for diagnosing renders: the exact JSON the model receives.
|
||||
logger.info("[IdeogramCaption] %s", caption)
|
||||
return io.NodeOutput(caption)
|
||||
|
||||
@@ -13,6 +13,24 @@ from comfy_api.latest import io
|
||||
logger = logging.getLogger("promptchain.ideogram_sampler")
|
||||
|
||||
|
||||
def _refusal_stats(image):
|
||||
"""(std, dominant-color fraction, per-channel means) of the first decoded frame.
|
||||
|
||||
A refusal is a near-uniform gray screen with the words 'Image blocked by safety
|
||||
filter' painted on it. A std-only test misses it — the rendered text lifts the
|
||||
pixel std past a flat-frame threshold — but such a frame is still ONE near-
|
||||
identical color over almost the whole canvas, so the fraction of pixels within
|
||||
a tight epsilon of the median color stays very high. A real image has a low
|
||||
dominant fraction, so the pair separates refusals from content."""
|
||||
img = image[0] if image.dim() == 4 else image
|
||||
flat = img.reshape(-1, img.shape[-1]).float()
|
||||
std = float(flat.std().item())
|
||||
median = flat.median(dim=0).values
|
||||
dom = float(((flat - median).abs().amax(dim=-1) < 0.04).float().mean().item())
|
||||
means = [round(float(m), 3) for m in flat.mean(dim=0)]
|
||||
return std, dom, means
|
||||
|
||||
|
||||
class IdeogramSamplerNode(io.ComfyNode):
|
||||
"""Ideogram 4 sampler with automatic retry past the model's stochastic
|
||||
'Image blocked by safety filter' refusal.
|
||||
@@ -21,10 +39,11 @@ class IdeogramSamplerNode(io.ComfyNode):
|
||||
fraction of seeds — an officially-acknowledged false-positive (higher for
|
||||
non-JSON prompts), baked into the weights and not disableable. It's
|
||||
seed-dependent, so re-rolling the noise seed escapes it. This node samples,
|
||||
VAE-decodes, and if the decoded image is a near-flat refusal frame (pixel
|
||||
std below `block_std`), it re-samples with the next seed up to `max_retries`
|
||||
times. Measured separation: refusal frames ~0.04 std, real images ~0.15-0.33
|
||||
(0-1 scale), so the default 0.08 sits cleanly between.
|
||||
VAE-decodes, and re-samples with the next seed up to `max_retries` times when
|
||||
the decoded frame reads as a refusal: either a near-flat frame (pixel std below
|
||||
`block_std`; refusal ~0.04 std vs real images ~0.15-0.33 on a 0-1 scale) OR a
|
||||
gray screen with the refusal TEXT painted on — the text lifts std past the flat
|
||||
threshold, so that variant is caught by a high dominant-color fraction instead.
|
||||
|
||||
Drop-in replacement for SamplerCustomAdvanced + VAEDecode (returns IMAGE).
|
||||
"""
|
||||
@@ -84,10 +103,13 @@ class IdeogramSamplerNode(io.ComfyNode):
|
||||
image = image.reshape(-1, image.shape[-3], image.shape[-2], image.shape[-1])
|
||||
last_image = image
|
||||
|
||||
std = float(image.float().std().item())
|
||||
blocked = std < block_std
|
||||
logger.info("[IdeogramSampler] attempt %d/%d seed=%d std=%.4f -> %s",
|
||||
attempt, max_retries, seed, std,
|
||||
std, dom, means = _refusal_stats(image)
|
||||
# The flat-gray case (std < block_std) plus the gray-screen-WITH-text
|
||||
# case (a very high dominant-color fraction), the latter gated on low-ish
|
||||
# std so a busy real image is never flagged.
|
||||
blocked = std < block_std or (std < 0.13 and dom > 0.70)
|
||||
logger.info("[IdeogramSampler] attempt %d/%d seed=%d std=%.4f dom=%.2f rgb_mean=%s -> %s",
|
||||
attempt, max_retries, seed, std, dom, means,
|
||||
"BLOCKED (retry)" if blocked else "ok")
|
||||
if not blocked:
|
||||
return io.NodeOutput(image)
|
||||
|
||||
+23
-12
@@ -17,7 +17,7 @@ _HASHED_BASE_RE = re.compile(r"^promptchain_pose_[0-9a-f]{12}$")
|
||||
|
||||
|
||||
class PoseStudioNode(io.ComfyNode):
|
||||
"""Posable 3D figure -> control map (depth / openpose).
|
||||
"""Posable 3D figure -> control map (clay render / white pose-anchor / depth).
|
||||
|
||||
The figure is posed in a Three.js viewport rendered in the node body.
|
||||
On every pose change the viewport renders the chosen control map, uploads
|
||||
@@ -85,11 +85,17 @@ class PoseStudioNode(io.ComfyNode):
|
||||
# silhouette intersection. Content-addressed sets upload masks right
|
||||
# after their map and can't go stale, so they pass this untouched.
|
||||
if paths and control_map_path and os.path.exists(control_map_path):
|
||||
map_mtime = os.path.getmtime(control_map_path)
|
||||
if max(os.path.getmtime(p) for p in paths) < map_mtime - 5:
|
||||
print(f"[PoseStudio] ignoring {len(paths)} stale mask file(s) "
|
||||
"(older than the control map — different scene)")
|
||||
paths = []
|
||||
try:
|
||||
map_mtime = os.path.getmtime(control_map_path)
|
||||
if max(os.path.getmtime(p) for p in paths) < map_mtime - 5:
|
||||
print(f"[PoseStudio] ignoring {len(paths)} stale mask file(s) "
|
||||
"(older than the control map — different scene)")
|
||||
paths = []
|
||||
except OSError:
|
||||
# A concurrent delete/overwrite (legacy in-place set) between the
|
||||
# glob and these stats — skip the staleness check rather than 500;
|
||||
# the per-file read loop below already tolerates missing files.
|
||||
pass
|
||||
|
||||
def mask_index(p):
|
||||
m = re.search(r"_mask(\d+)\.png$", p)
|
||||
@@ -186,10 +192,15 @@ class PoseStudioNode(io.ComfyNode):
|
||||
if control_map and control_map.strip():
|
||||
path = folder_paths.get_annotated_filepath(control_map)
|
||||
if os.path.exists(path):
|
||||
m = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
m.update(f.read())
|
||||
m.update(pose_state.encode("utf-8"))
|
||||
m.update(dims.encode("utf-8"))
|
||||
return m.digest().hex()
|
||||
try:
|
||||
m = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
m.update(f.read())
|
||||
m.update(pose_state.encode("utf-8"))
|
||||
m.update(dims.encode("utf-8"))
|
||||
return m.digest().hex()
|
||||
except OSError:
|
||||
# File vanished between exists() and open (re-pose / GC race) —
|
||||
# fall through to the no-file fingerprint rather than crashing.
|
||||
pass
|
||||
return pose_state + dims
|
||||
|
||||
+17
-20
@@ -17,6 +17,7 @@ from comfy_api.latest import io
|
||||
|
||||
from ..core.compiler import (figure_entity_names, region_figure_count,
|
||||
region_figure_indices, region_orphans)
|
||||
from ..core.crop_windows import crop_hint_to_window as _crop_hint_to_window
|
||||
|
||||
log = logging.getLogger("promptchain.regional_detailer")
|
||||
|
||||
@@ -203,17 +204,6 @@ def _crop_noise_mask(crop_h: int, crop_w: int, face_rect, feather: int,
|
||||
return box * m.view(crop_h, crop_w)
|
||||
|
||||
|
||||
def _crop_hint_to_window(t, win, img_h, img_w, sh, sw):
|
||||
"""Resize a (...,H,W) hint to canvas, crop the detail window, resize to the
|
||||
sampled crop dims — the per-crop remap the tiled upscaler does."""
|
||||
cx1, cy1, cx2, cy2 = win
|
||||
t = t.float()
|
||||
if t.shape[-2:] != (img_h, img_w):
|
||||
t = F.interpolate(t, size=(img_h, img_w), mode="bilinear", align_corners=False)
|
||||
t = t[..., cy1:cy2, cx1:cx2]
|
||||
return F.interpolate(t, size=(sh, sw), mode="bilinear", align_corners=False)
|
||||
|
||||
|
||||
def _crop_cond_to_window(cond, mask_win, ctrl_win, img_h: int, img_w: int, sh: int, sw: int):
|
||||
"""Crop every region mask AND ControlNet hint in a conditioning to the
|
||||
detail window and rescale to the sampled crop dims, so RegionalConditioning's
|
||||
@@ -245,24 +235,31 @@ def _crop_cond_to_window(cond, mask_win, ctrl_win, img_h: int, img_w: int, sh: i
|
||||
mm = m.float()
|
||||
if mm.dim() == 2:
|
||||
mm = mm.unsqueeze(0)
|
||||
new_meta["mask"] = _crop_hint_to_window(mm.unsqueeze(1), mask_win, img_h, img_w, sh, sw).squeeze(1)
|
||||
new_meta["mask"] = _crop_hint_to_window(
|
||||
mm.unsqueeze(1), mask_win, img_h, img_w, sh, sw, mode="nearest-exact").squeeze(1)
|
||||
if ctrl is not None:
|
||||
# Walk the ControlNet chain, cropping each hint — mirrors the
|
||||
# tiled upscaler's crop_controlnet. Best-effort: a control object
|
||||
# whose internals differ just keeps its (misaligned) hint rather
|
||||
# than 500ing the whole inpaint, so depth degrades, never breaks.
|
||||
# Walk the ControlNet chain, cropping each hint — mirrors the tiled
|
||||
# upscaler's crop_controlnet. Per-link best-effort: a link with no
|
||||
# hint (or an odd internal layout) is left as-is so it degrades only
|
||||
# itself, instead of one bad link reverting the WHOLE chain to the
|
||||
# uncropped/misaligned hint. The outer guard keeps a catastrophic
|
||||
# copy failure from 500ing the inpaint — depth degrades, never breaks.
|
||||
try:
|
||||
new_ctrl = ctrl.copy()
|
||||
new_meta["control"] = new_ctrl
|
||||
c, nc = ctrl, new_ctrl
|
||||
while c is not None:
|
||||
nc.cond_hint_original = _crop_hint_to_window(
|
||||
nc.cond_hint_original, ctrl_win, img_h, img_w, sh, sw)
|
||||
try:
|
||||
if nc.cond_hint_original is not None:
|
||||
nc.cond_hint_original = _crop_hint_to_window(
|
||||
nc.cond_hint_original, ctrl_win, img_h, img_w, sh, sw)
|
||||
except Exception as e: # noqa: BLE001 — one link only
|
||||
log.warning("control-hint crop failed for one link (%s); leaving it uncropped", e)
|
||||
c = c.previous_controlnet
|
||||
nc.set_previous_controlnet(c.copy() if c is not None else None)
|
||||
nc = nc.previous_controlnet
|
||||
except Exception as e: # noqa: BLE001 — diagnostic, never fatal
|
||||
log.warning("control-hint crop failed (%s); leaving hint uncropped", e)
|
||||
except Exception as e: # noqa: BLE001 — chain copy failed; never fatal
|
||||
log.warning("control-hint chain copy failed (%s); leaving hint uncropped", e)
|
||||
new_meta["control"] = ctrl
|
||||
out.append([emb, new_meta])
|
||||
return out
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
[project]
|
||||
name = "comfyui-promptchain"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
description = "Prompt-authoring and image-iteration suite for ComfyUI: structured prompts, 3D posing, model awareness, regional control, and layered editing."
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Port v1 characters into v2 tag-builder chip format.
|
||||
|
||||
Restructures each character's OWN base_tags + base_natlang paragraph into:
|
||||
- appearance_chip_tags (JSON array of appearance_items.item_tag that slot)
|
||||
- base_extras (tokens with no matching chip, ride alongside)
|
||||
- character_chip_overrides (per-chip rich natlang carved from the paragraph)
|
||||
- natlang_status = 'normalized' (so the v2 picker shows them)
|
||||
- base_tags recomposed from the above (the cached flat projection)
|
||||
|
||||
The LLM call is RESTRUCTURING provided text — it needs no knowledge of the
|
||||
character, so accuracy does not depend on how obscure the character is.
|
||||
|
||||
Runs on the Anthropic API (your usage credits), independent of any Claude Code
|
||||
session limit. Safe by default: --dry-run prints intended writes and changes
|
||||
nothing. Resumable: only touches characters still natlang_status='unprocessed'.
|
||||
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
python port_v1_to_v2.py --dry-run --limit 5 # preview
|
||||
python port_v1_to_v2.py --limit 50 # port 50 for real
|
||||
python port_v1_to_v2.py # port all remaining
|
||||
"""
|
||||
import argparse, json, os, re, sys, sqlite3, time
|
||||
|
||||
DB_PATH = r"C:/comfyui/comfyui/custom_nodes/ComfyUI-PromptChain/data/tag-builder/tag-builder.db"
|
||||
MODEL = "claude-sonnet-4-5"
|
||||
PALETTE_GROUPS = ["modifiers", "hair_color", "hair_length", "hair_style", "hair_accessories",
|
||||
"eye_color", "eyes", "skin", "body_type", "breast_size", "ass_size",
|
||||
"body_marks", "fantasy"]
|
||||
|
||||
# Near-synonym tokens that should map onto an existing palette chip instead of
|
||||
# falling to base_extras. Keeps common danbooru variants slottable.
|
||||
VOCAB_ALIASES = {
|
||||
"silver_hair": "white_hair",
|
||||
"platinum_blonde_hair": "blonde_hair",
|
||||
"dark_green_eyes": "green_eyes",
|
||||
"light_brown_hair": "brown_hair",
|
||||
}
|
||||
|
||||
PORT_TOOL = {
|
||||
"name": "emit_port",
|
||||
"description": "Return the v2 port for each character.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {"type": "string"},
|
||||
"chip_tags": {"type": "array", "items": {"type": "string"},
|
||||
"description": "base_tags tokens that are verbatim palette keys"},
|
||||
"base_extras": {"type": "string", "description": "comma-joined non-palette tokens"},
|
||||
"overrides": {"type": "array", "items": {"type": "object", "properties": {
|
||||
"chip_tag": {"type": "string"}, "natlang": {"type": "string"}},
|
||||
"required": ["chip_tag", "natlang"]},
|
||||
"description": "per-chip rich natlang carved ONLY from the paragraph"},
|
||||
"terse_natlang": {"type": "string"},
|
||||
},
|
||||
"required": ["tag", "chip_tags", "base_extras", "overrides", "terse_natlang"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["results"],
|
||||
},
|
||||
}
|
||||
|
||||
SYSTEM_RULES = (
|
||||
"You PORT v1 characters into the v2 tag-builder chip format. This is pure RESTRUCTURING "
|
||||
"of each character's OWN provided data. NEVER add an appearance fact not present in that "
|
||||
"character's base_tags or base_natlang. You need no outside knowledge of who they are.\n"
|
||||
"For each character:\n"
|
||||
"1. chip_tags: every base_tags token (weights stripped, identity lead and the character's own "
|
||||
"tag excluded) that is a VERBATIM key in the palette below.\n"
|
||||
"2. base_extras: comma-joined base_tags tokens that are NOT palette keys.\n"
|
||||
"3. overrides: for each chip the paragraph describes more specifically than its generic phrase, "
|
||||
"a short natlang using ONLY paragraph wording. Never invent; never restate the generic; omit "
|
||||
"chips the paragraph does not elaborate.\n"
|
||||
"4. terse_natlang: one line, display name + series only.\n"
|
||||
"Call emit_port with one result per character."
|
||||
)
|
||||
|
||||
|
||||
def strip_weight(tok):
|
||||
# Greedy inner capture so a character tag that itself contains parens
|
||||
# (e.g. "(artoria_pendragon_(fate):1.1)") strips to its bare form, not a
|
||||
# truncated fragment that would leak into base_extras.
|
||||
tok = tok.strip()
|
||||
m = re.match(r"^\((.+):[0-9.]+\)$", tok)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return tok
|
||||
|
||||
|
||||
def load_palette(conn):
|
||||
palette = {}
|
||||
for g in PALETTE_GROUPS:
|
||||
palette[g] = {r["item_tag"]: (r["base_natlang"] or "")
|
||||
for r in conn.execute(
|
||||
"SELECT item_tag, base_natlang FROM appearance_items "
|
||||
"WHERE item_group=? ORDER BY sort_order, item_tag", (g,))}
|
||||
return palette
|
||||
|
||||
|
||||
def palette_keys(palette):
|
||||
keys = set()
|
||||
for g in palette.values():
|
||||
keys.update(g.keys())
|
||||
return keys
|
||||
|
||||
|
||||
def select_pending(conn, limit):
|
||||
q = ("SELECT tag, display, series, base_tags, base_natlang FROM characters "
|
||||
"WHERE natlang_status='unprocessed' AND base_tags IS NOT NULL AND base_tags!=''")
|
||||
if limit:
|
||||
q += f" ORDER BY post_count DESC LIMIT {int(limit)}"
|
||||
return conn.execute(q).fetchall()
|
||||
|
||||
|
||||
def compose_base_tags(tag, base_extras, chip_tags):
|
||||
parts = [f"({tag}:1.1)"]
|
||||
if base_extras.strip():
|
||||
parts.append(base_extras.strip())
|
||||
parts.extend(chip_tags)
|
||||
return ", ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def reconcile(conn, char, result, valid_keys):
|
||||
"""Trust the source base_tags, not the model, for which tokens exist.
|
||||
Re-derive chips/extras mechanically from base_tags so a model slip cannot
|
||||
invent or drop a token; keep only the model's overrides (the real value-add)
|
||||
after confirming each targets a real chip."""
|
||||
tag = char["tag"]
|
||||
tokens = [strip_weight(t) for t in (char["base_tags"] or "").split(",") if t.strip()]
|
||||
chips, extras = [], []
|
||||
for tok in tokens:
|
||||
if tok == tag:
|
||||
continue
|
||||
key = VOCAB_ALIASES.get(tok, tok)
|
||||
if key in valid_keys and key not in chips:
|
||||
chips.append(key)
|
||||
elif key not in valid_keys:
|
||||
extras.append(tok)
|
||||
chip_set = set(chips)
|
||||
overrides = [o for o in result.get("overrides", [])
|
||||
if o.get("chip_tag") in chip_set and (o.get("natlang") or "").strip()]
|
||||
return {
|
||||
"tag": tag,
|
||||
"chip_tags": chips,
|
||||
"base_extras": ", ".join(extras),
|
||||
"overrides": overrides,
|
||||
"terse_natlang": (result.get("terse_natlang") or f"{char['display']}.").strip(),
|
||||
}
|
||||
|
||||
|
||||
def ensure_backup_column(conn):
|
||||
# base_natlang is reduced to a terse identity line on port; preserve the
|
||||
# original rich paragraph so the change is reversible and the dropped
|
||||
# details (skin/height/build) stay available for later enrichment.
|
||||
try:
|
||||
conn.execute("ALTER TABLE characters ADD COLUMN base_natlang_orig TEXT")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
pass # already exists
|
||||
|
||||
|
||||
def write_port(conn, port):
|
||||
tag = port["tag"]
|
||||
conn.execute(
|
||||
"UPDATE characters SET base_natlang_orig = COALESCE(base_natlang_orig, base_natlang) WHERE tag=?",
|
||||
(tag,))
|
||||
conn.execute(
|
||||
"UPDATE characters SET appearance_chip_tags=?, base_extras=?, base_natlang=?, "
|
||||
"base_tags=?, natlang_status='normalized' WHERE tag=?",
|
||||
(json.dumps(port["chip_tags"]), port["base_extras"], port["terse_natlang"],
|
||||
compose_base_tags(tag, port["base_extras"], port["chip_tags"]), tag),
|
||||
)
|
||||
conn.execute("DELETE FROM character_chip_overrides WHERE character_tag=?", (tag,))
|
||||
for o in port["overrides"]:
|
||||
conn.execute(
|
||||
"INSERT INTO character_chip_overrides (character_tag, chip_tag, natlang, status) "
|
||||
"VALUES (?,?,?, 'normalized')", (tag, o["chip_tag"], o["natlang"]))
|
||||
|
||||
|
||||
def call_api(client, palette, batch):
|
||||
palette_text = json.dumps(palette, ensure_ascii=False)
|
||||
chars = [{"tag": r["tag"], "display": r["display"], "series": r["series"],
|
||||
"base_tags": r["base_tags"], "base_natlang": r["base_natlang"]} for r in batch]
|
||||
msg = client.messages.create(
|
||||
model=MODEL, max_tokens=4096,
|
||||
system=[
|
||||
{"type": "text", "text": SYSTEM_RULES},
|
||||
# cache_control keeps the (large, identical) palette cheap across calls
|
||||
{"type": "text", "text": "PALETTE (group -> {item_tag: generic_natlang}):\n" + palette_text,
|
||||
"cache_control": {"type": "ephemeral"}},
|
||||
],
|
||||
tools=[PORT_TOOL], tool_choice={"type": "tool", "name": "emit_port"},
|
||||
messages=[{"role": "user", "content":
|
||||
"Port these characters:\n" + json.dumps(chars, ensure_ascii=False)}],
|
||||
)
|
||||
for block in msg.content:
|
||||
if block.type == "tool_use":
|
||||
return block.input.get("results", [])
|
||||
return []
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true", help="print intended writes, change nothing")
|
||||
ap.add_argument("--limit", type=int, default=0, help="only the N most popular pending chars")
|
||||
ap.add_argument("--batch", type=int, default=8, help="characters per API call")
|
||||
ap.add_argument("--db", default=DB_PATH)
|
||||
args = ap.parse_args()
|
||||
|
||||
conn = sqlite3.connect(args.db)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.text_factory = lambda x: x.decode("utf-8", "replace") # some rows have bad bytes
|
||||
if not args.dry_run:
|
||||
ensure_backup_column(conn)
|
||||
palette = load_palette(conn)
|
||||
valid_keys = palette_keys(palette)
|
||||
pending = select_pending(conn, args.limit)
|
||||
print(f"{len(pending)} characters to port (dry_run={args.dry_run}, batch={args.batch})")
|
||||
if not pending:
|
||||
return
|
||||
|
||||
client = None
|
||||
if not args.dry_run:
|
||||
from anthropic import Anthropic # pip install anthropic
|
||||
client = Anthropic() # reads ANTHROPIC_API_KEY
|
||||
|
||||
done = 0
|
||||
for i in range(0, len(pending), args.batch):
|
||||
batch = pending[i:i + args.batch]
|
||||
by_tag = {r["tag"]: r for r in batch}
|
||||
if args.dry_run:
|
||||
# No API spend: derive chips/extras mechanically, skip override text.
|
||||
results = [{"tag": r["tag"], "overrides": [], "terse_natlang": f"{r['display']}."} for r in batch]
|
||||
else:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
results = call_api(client, palette, batch)
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" API error ({e}); retry {attempt+1}/3", file=sys.stderr)
|
||||
time.sleep(2 * (attempt + 1))
|
||||
else:
|
||||
print(" batch failed after retries; skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
for res in results:
|
||||
char = by_tag.get(res.get("tag"))
|
||||
if not char:
|
||||
continue
|
||||
port = reconcile(conn, char, res, valid_keys)
|
||||
if args.dry_run:
|
||||
print(f" {port['tag']:40} chips={len(port['chip_tags'])} "
|
||||
f"extras=[{port['base_extras']}] overrides={len(port['overrides'])}")
|
||||
else:
|
||||
write_port(conn, port)
|
||||
done += 1
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
print(f" committed {done}/{len(pending)}")
|
||||
|
||||
print("done." if not args.dry_run else "dry-run complete (no changes written).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the crop-window remap (core/crop_windows.py).
|
||||
|
||||
Locks the invariants of the most-regressed geometry in the codebase: output
|
||||
dims, the nearest-vs-bilinear mask choice (the confirmed region-edge bleed fix),
|
||||
full-canvas identity, and that distinct windows select distinct source regions
|
||||
(the mask_win != ctrl_win moved-content mechanism). torch-only; run anywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
import torch
|
||||
|
||||
_HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"crop_windows", os.path.join(_HERE, "core", "crop_windows.py"))
|
||||
_cw = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_cw)
|
||||
crop = _cw.crop_hint_to_window
|
||||
|
||||
_failures = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'PASS' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
_failures.append(name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
H, W = 120, 80 # non-square canvas (portrait), the case aspect bugs hide in
|
||||
|
||||
# 1. Output dims are always (sh, sw) regardless of input size or window.
|
||||
src = torch.rand(1, 1, 64, 48)
|
||||
out = crop(src, (10, 20, 70, 100), H, W, 32, 24)
|
||||
check("output dims == (sh, sw)", tuple(out.shape[-2:]) == (32, 24))
|
||||
|
||||
# 2. nearest-exact keeps a binary mask binary (no soft bleed band).
|
||||
mask = torch.zeros(1, 1, H, W)
|
||||
mask[..., 40:80, 20:60] = 1.0
|
||||
out_n = crop(mask, (0, 0, W, H), H, W, 60, 40, mode="nearest-exact")
|
||||
uniq = set(torch.unique(out_n).tolist())
|
||||
check("nearest mask stays binary {0,1}", uniq.issubset({0.0, 1.0}))
|
||||
|
||||
# 3. bilinear DOES introduce an intermediate edge value — proves the mode
|
||||
# actually changes behaviour (so the mask fix is meaningful, not a no-op).
|
||||
# Upscale a hard edge so it must ramp (an exact-2x downsample with the edge
|
||||
# on an even boundary would sample exactly 0/1 and hide the difference).
|
||||
small = torch.zeros(1, 1, 8, 8)
|
||||
small[..., 4:, :] = 1.0
|
||||
up_b = crop(small, (0, 0, 8, 8), 8, 8, 32, 32, mode="bilinear")
|
||||
up_n = crop(small, (0, 0, 8, 8), 8, 8, 32, 32, mode="nearest-exact")
|
||||
check("bilinear softens the edge (mode matters)",
|
||||
bool(((up_b > 0.01) & (up_b < 0.99)).any()))
|
||||
check("nearest stays hard on the same upscale",
|
||||
set(torch.unique(up_n).tolist()).issubset({0.0, 1.0}))
|
||||
|
||||
# 4. Full-canvas window is identity-ish: a uniform field stays uniform.
|
||||
flat = torch.full((1, 1, H, W), 0.5)
|
||||
out_f = crop(flat, (0, 0, W, H), H, W, H, W)
|
||||
check("full-canvas uniform field preserved", torch.allclose(out_f, flat, atol=1e-5))
|
||||
|
||||
# 5. A window over an all-ones sub-region returns ~all-ones; a disjoint
|
||||
# window returns ~all-zeros. This is what makes a tile sample the right
|
||||
# region — and the basis of mask_win != ctrl_win moved-content selection.
|
||||
field = torch.zeros(1, 1, H, W)
|
||||
field[..., 0:40, 0:40] = 1.0 # top-left block is the "content"
|
||||
inside = crop(field, (0, 0, 40, 40), H, W, 16, 16, mode="nearest-exact")
|
||||
outside = crop(field, (40, 60, 80, 120), H, W, 16, 16, mode="nearest-exact")
|
||||
check("window over content -> all ones", bool((inside > 0.5).all()))
|
||||
check("disjoint window -> all zeros", bool((outside < 0.5).all()))
|
||||
|
||||
# 6. Distinct windows select DISTINCT regions of the same source (the
|
||||
# mask_win vs ctrl_win divergence relies on this).
|
||||
grad = torch.linspace(0, 1, W).view(1, 1, 1, W).expand(1, 1, H, W).contiguous()
|
||||
left = crop(grad, (0, 0, 20, H), H, W, 8, 8)
|
||||
right = crop(grad, (60, 0, 80, H), H, W, 8, 8)
|
||||
check("distinct windows -> distinct content", float(left.mean()) < float(right.mean()))
|
||||
|
||||
# 7. Input already at canvas size skips the upscale but still crops+resizes.
|
||||
at_canvas = torch.rand(1, 1, H, W)
|
||||
out_ac = crop(at_canvas, (10, 10, 50, 50), H, W, 20, 20)
|
||||
check("pre-sized input crops to (sh,sw)", tuple(out_ac.shape[-2:]) == (20, 20))
|
||||
|
||||
print(f"\n{'ALL PASS' if not _failures else str(len(_failures)) + ' FAILURE(S)'}")
|
||||
return 1 if _failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for regional prompt extraction + figure binding (core/compiler.py).
|
||||
|
||||
Locks the two confirmed correctness fixes:
|
||||
- $block bodies are extracted by brace DEPTH, so a wildcard ({a|b}) inside a
|
||||
region no longer truncates it or leaks the remainder into the global prompt.
|
||||
- a non-object pose JSON (scalar/array/null) degrades gracefully instead of
|
||||
crashing the single-source-of-truth entity resolver.
|
||||
Plus sanity coverage of the name -> id -> order binding ladder and orphan drop.
|
||||
torch-free; run anywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"pc_compiler", os.path.join(_HERE, "core", "compiler.py"))
|
||||
_c = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_c)
|
||||
|
||||
_failures = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'PASS' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
_failures.append(name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# ── brace-depth extraction (the HIGH fix) ────────────────────────────────
|
||||
spans = list(_c._iter_region_spans("$mannequin1 { {red|blue} dress }"))
|
||||
check("wildcard brace kept inside region body",
|
||||
len(spans) == 1 and "{red|blue}" in spans[0][1] and "dress" in spans[0][1])
|
||||
|
||||
# global must NOT receive the truncated remainder
|
||||
glob = _c._remove_region_spans("$m1{ {a|b} dress } and a tree",
|
||||
list(_c._iter_region_spans("$m1{ {a|b} dress } and a tree")))
|
||||
check("global free of region body leak", "dress" not in glob and "and a tree" in glob)
|
||||
|
||||
# nested parens + wildcard
|
||||
sp2 = list(_c._iter_region_spans("$hero { (a|{red|blue}) } tail"))
|
||||
check("nested braces fully captured",
|
||||
len(sp2) == 1 and sp2[0][1].strip() == "(a|{red|blue})")
|
||||
|
||||
# adjacent regions still split correctly
|
||||
sp3 = list(_c._iter_region_spans("$a{ x } $b{ y }"))
|
||||
check("adjacent regions both extracted",
|
||||
[s[0] for s in sp3] == ["a", "b"])
|
||||
|
||||
# strip_region_markers keeps nested body inline (flat/non-regional path)
|
||||
flat = _c.strip_region_markers("$m1{ hair {a|b} } scene")
|
||||
check("strip keeps nested body inline",
|
||||
"{a|b}" in flat and "$m1" not in flat and "scene" in flat)
|
||||
|
||||
# unbalanced brace degrades without hanging
|
||||
sp4 = list(_c._iter_region_spans("$x{ no close here"))
|
||||
check("unbalanced region handled", len(sp4) == 1 and "no close" in sp4[0][1])
|
||||
|
||||
# compile_regions end-to-end: region text non-empty, global clean
|
||||
obj = _c.compile_regions("a cat $m1{ red {hat|cap} } in a park")
|
||||
body_ok = obj["regions"] and obj["regions"][0]["text"]
|
||||
check("compile_regions: region body compiled non-empty", bool(body_ok))
|
||||
check("compile_regions: global has outside text, not region body",
|
||||
"cat" in obj["global"] and "park" in obj["global"] and "red" not in obj["global"])
|
||||
|
||||
# ── brace-internal newlines don't comma-pollute a wildcard ───────────────
|
||||
# A wildcard split across lines is layout, not tag separators.
|
||||
p, _, _ = _c.compile_prompt("a woman in a {\n red|blue\n } sundress")
|
||||
check("multi-line wildcard: no mid-phrase comma before the option",
|
||||
p in ("a woman in a red sundress", "a woman in a blue sundress"))
|
||||
# nested wildcard across lines stays clean
|
||||
p, _, _ = _c.compile_prompt("a {\n big | {tiny|small}\n} cat")
|
||||
check("nested multi-line wildcard resolves clean", "," not in p and "cat" in p)
|
||||
# but a top-level newline (lazy tag list) STILL comma-joins
|
||||
p, _, _ = _c.compile_prompt("masterpiece\n{detailed|intricate}\nforest")
|
||||
check("lazy multi-line tag list still comma-joins", p.count(",") == 2)
|
||||
# a multi-line wildcard OPTION keeps internal commas (newline→comma), but no
|
||||
# leading/trailing junk comma from the option edges
|
||||
picks = {_c.compile_prompt("{\na\nb\n|\nc\nd\n}")[0] for _ in range(60)}
|
||||
check("multi-line wildcard options -> 'a, b' / 'c, d'", picks == {"a, b", "c, d"})
|
||||
picks = {_c.compile_prompt("{\nred\n|\nblue\n}")[0] for _ in range(60)}
|
||||
check("split single-tag wildcard -> clean (no edge comma)", picks == {"red", "blue"})
|
||||
|
||||
# ── example $blocks inside // comments are NOT extracted as regions ───────
|
||||
scaffolded = ("// help: $background{ a forest } and $alice{ red dress }, $bob{ blue suit }\n"
|
||||
"$region1 {\na cat\n}\n\n$region2 {\na dog\n}\n\na grassy field")
|
||||
obj = _c.compile_regions(scaffolded)
|
||||
check("comment-scaffold $blocks ignored (only real regions)",
|
||||
[r["name"] for r in obj["regions"]] == ["region1", "region2"])
|
||||
check("comment-scaffold global is the real global",
|
||||
obj["global"] == "a grassy field")
|
||||
|
||||
# ── non-object pose JSON guard (the LOW fix) ─────────────────────────────
|
||||
for payload in ("42", "[]", "null", '"x"', "3.14"):
|
||||
try:
|
||||
names, nfig = _c._pose_entity_index(payload)
|
||||
ok = names == [] and nfig is None
|
||||
except Exception:
|
||||
ok = False
|
||||
check(f"non-object pose {payload!r} degrades to ([],None)", ok)
|
||||
|
||||
# ── binding ladder sanity ────────────────────────────────────────────────
|
||||
pose = '{"regionEntities":[{"kind":"figure","name":"alice"},{"kind":"figure","name":"bob"}]}'
|
||||
regions = [{"id": 1, "name": "bob", "text": "x"}, {"id": 2, "name": "alice", "text": "y"}]
|
||||
idx = _c.region_figure_indices(regions, pose)
|
||||
check("name-match binds across order (bob->1, alice->0)", idx == [1, 0])
|
||||
|
||||
# orphan: a $block whose figure was deleted (name unknown, id past figures)
|
||||
orphan_regions = [{"id": 1, "name": "alice", "text": "x"},
|
||||
{"id": 9, "name": "ghost", "text": "y"}]
|
||||
orphans = _c.region_orphans(orphan_regions, pose)
|
||||
check("deleted-figure block flagged orphan", orphans == [False, True])
|
||||
|
||||
print(f"\n{'ALL PASS' if not _failures else str(len(_failures)) + ' FAILURE(S)'}")
|
||||
return 1 if _failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural validator for PromptChain workflow templates.
|
||||
|
||||
Templates are the contract surface between every model architecture and the
|
||||
regional/depth/pose nodes — a dangling link or bad slot index silently breaks a
|
||||
template in the picker. This catches those before they ship; run it on /deploy.
|
||||
|
||||
It validates STRUCTURE, not render semantics (it can't tell a depth ControlNet
|
||||
is wired to the wrong stream — only a render does). Checks:
|
||||
- valid JSON, required top-level fields
|
||||
- scope.type is one of the known kinds, with the fields that kind needs
|
||||
- every node has a string `type`
|
||||
- every connection references existing node indices and non-negative slots
|
||||
- anchorConnections reference existing nodes/slots
|
||||
- template `id` is unique across the directory
|
||||
- duplicate (name + scope) pairs are reported (two indistinguishable entries)
|
||||
|
||||
Exit code 0 = clean, 1 = at least one error. Warnings never fail the build.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"data", "templates")
|
||||
|
||||
SCOPE_TYPES = {"architecture", "family", "version", "model"}
|
||||
SCOPE_REQUIRED = {
|
||||
"architecture": ["architecture"],
|
||||
"family": ["architecture", "family"],
|
||||
"version": ["model_hash"],
|
||||
"model": ["model_name"], # display_name is accepted as an alias below
|
||||
}
|
||||
|
||||
|
||||
def _validate(path: str, errors: list, warnings: list, ids: dict, signatures: dict) -> None:
|
||||
name = os.path.basename(path)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
tpl = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
errors.append(f"{name}: not valid JSON — {e}")
|
||||
return
|
||||
if not isinstance(tpl, dict):
|
||||
errors.append(f"{name}: top level must be an object")
|
||||
return
|
||||
|
||||
if not tpl.get("name"):
|
||||
errors.append(f"{name}: missing 'name'")
|
||||
tid = tpl.get("id")
|
||||
if not tid:
|
||||
errors.append(f"{name}: missing 'id'")
|
||||
elif tid in ids:
|
||||
errors.append(f"{name}: duplicate id '{tid}' (also in {ids[tid]})")
|
||||
else:
|
||||
ids[tid] = name
|
||||
|
||||
scope = tpl.get("scope")
|
||||
if not isinstance(scope, dict):
|
||||
errors.append(f"{name}: missing/invalid 'scope'")
|
||||
else:
|
||||
st = scope.get("type", "architecture")
|
||||
if st not in SCOPE_TYPES:
|
||||
errors.append(f"{name}: scope.type '{st}' not one of {sorted(SCOPE_TYPES)}")
|
||||
else:
|
||||
req = SCOPE_REQUIRED.get(st, [])
|
||||
for key in req:
|
||||
if key == "model_name" and (scope.get("model_name") or scope.get("display_name")):
|
||||
continue
|
||||
if not scope.get(key):
|
||||
errors.append(f"{name}: scope.type '{st}' requires '{key}'")
|
||||
|
||||
nodes = tpl.get("nodes")
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
errors.append(f"{name}: 'nodes' must be a non-empty array")
|
||||
nodes = []
|
||||
for i, node in enumerate(nodes):
|
||||
if not isinstance(node, dict) or not isinstance(node.get("type"), str) or not node.get("type"):
|
||||
errors.append(f"{name}: node[{i}] missing a string 'type'")
|
||||
|
||||
n = len(nodes)
|
||||
|
||||
def _check_link(label: str, link: dict, has_from: bool) -> None:
|
||||
if not isinstance(link, dict):
|
||||
errors.append(f"{name}: {label} is not an object")
|
||||
return
|
||||
if has_from:
|
||||
fi = link.get("from_node_idx")
|
||||
if not isinstance(fi, int) or not (0 <= fi < n):
|
||||
errors.append(f"{name}: {label} from_node_idx {fi} out of range (0..{n - 1})")
|
||||
for slot_key in ("from_slot", "to_slot"):
|
||||
sv = link.get(slot_key)
|
||||
if not isinstance(sv, int) or sv < 0:
|
||||
errors.append(f"{name}: {label} {slot_key} {sv!r} must be a non-negative int")
|
||||
ti = link.get("to_node_idx")
|
||||
if not isinstance(ti, int) or not (0 <= ti < n):
|
||||
errors.append(f"{name}: {label} to_node_idx {ti} out of range (0..{n - 1})")
|
||||
|
||||
conns = tpl.get("connections", [])
|
||||
if not isinstance(conns, list):
|
||||
errors.append(f"{name}: 'connections' must be an array")
|
||||
else:
|
||||
for j, link in enumerate(conns):
|
||||
_check_link(f"connections[{j}]", link, has_from=True)
|
||||
|
||||
anchors = tpl.get("anchorConnections", [])
|
||||
if isinstance(anchors, list):
|
||||
for j, link in enumerate(anchors):
|
||||
_check_link(f"anchorConnections[{j}]", link, has_from=False)
|
||||
|
||||
# Two templates the matcher can't tell apart (same name + identical scope)
|
||||
# both surface for the same model — usually an authoring mistake.
|
||||
if isinstance(scope, dict) and tpl.get("name"):
|
||||
sig = (tpl.get("name"), json.dumps(scope, sort_keys=True))
|
||||
signatures[sig].append(name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not os.path.isdir(TEMPLATES_DIR):
|
||||
print(f"templates dir not found: {TEMPLATES_DIR}")
|
||||
return 1
|
||||
errors: list = []
|
||||
warnings: list = []
|
||||
ids: dict = {}
|
||||
signatures: dict = defaultdict(list)
|
||||
|
||||
files = sorted(f for f in os.listdir(TEMPLATES_DIR)
|
||||
if f.endswith(".json") and not f.startswith("_"))
|
||||
for f in files:
|
||||
_validate(os.path.join(TEMPLATES_DIR, f), errors, warnings, ids, signatures)
|
||||
|
||||
for sig, names in signatures.items():
|
||||
if len(names) > 1:
|
||||
warnings.append(f"same name+scope on {len(names)} templates: {', '.join(names)} "
|
||||
f"(name={sig[0]!r})")
|
||||
|
||||
print(f"validated {len(files)} templates")
|
||||
for w in warnings:
|
||||
print(f" WARN {w}")
|
||||
for e in errors:
|
||||
print(f" ERROR {e}")
|
||||
if errors:
|
||||
print(f"\n{len(errors)} error(s)")
|
||||
return 1
|
||||
print("OK — no structural errors" + (f" ({len(warnings)} warning(s))" if warnings else ""))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -330,7 +330,7 @@
|
||||
|
||||
<!-- prompt output tab -->
|
||||
{#if activeTab === "prompt"}
|
||||
<PromptOutput compiledOutput={shared.compiledOutput} compiledNegOutput={shared.compiledNegOutput} />
|
||||
<PromptOutput compiledOutput={shared.compiledOutput} compiledNegOutput={shared.compiledNegOutput} compiledRegions={shared.compiledRegions} />
|
||||
{/if}
|
||||
|
||||
<!-- console log tab — kept mounted to preserve log history across tab switches -->
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script>
|
||||
// Compiled prompt text display — positive + negative sections.
|
||||
// Compiled prompt text display. For a regional prompt ($name{} blocks) it shows
|
||||
// the Global + per-figure breakdown the flat positive line can't represent;
|
||||
// otherwise the plain positive + negative sections.
|
||||
|
||||
let {
|
||||
compiledOutput = "",
|
||||
compiledNegOutput = "",
|
||||
compiledRegions = "", // JSON {global, regions:[{name,text}], negative} sent with the compile
|
||||
} = $props();
|
||||
|
||||
const _HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
@@ -11,10 +14,34 @@
|
||||
return String(text).replace(/[&<>"']/g, c => _HTML_ESCAPES[c]);
|
||||
}
|
||||
|
||||
// A non-empty regions list means the prompt is regional — show the per-figure
|
||||
// split. Empty list / parse failure falls back to the flat positive.
|
||||
let regional = $derived.by(() => {
|
||||
if (!compiledRegions) return null;
|
||||
try {
|
||||
const o = JSON.parse(compiledRegions);
|
||||
return o && Array.isArray(o.regions) && o.regions.length ? o : null;
|
||||
} catch { return null; }
|
||||
});
|
||||
|
||||
let html = $derived.by(() => {
|
||||
if (!compiledOutput && !compiledNegOutput) {
|
||||
if (!compiledOutput && !compiledNegOutput && !regional) {
|
||||
return '<span class="pcr-output-panel-placeholder">Queue workflow to see prompt output</span>';
|
||||
}
|
||||
if (regional) {
|
||||
// Regions first, Global last — matches the image viewer's order.
|
||||
const regEmpty = '<span class="pcr-output-panel-placeholder">(empty → uses global)</span>';
|
||||
let result = "";
|
||||
for (const reg of regional.regions) {
|
||||
const body = (reg.text || "").trim();
|
||||
result += `<span class="pcr-output-panel-label-region">$${escapeHtml(reg.name || "")}</span>\n${body ? escapeHtml(body) : regEmpty}\n\n`;
|
||||
}
|
||||
const g = (regional.global || "").trim();
|
||||
result += `<span class="pcr-output-panel-label-pos">Global:</span>\n${g ? escapeHtml(g) : '<span class="pcr-output-panel-placeholder">(none)</span>'}`;
|
||||
const neg = regional.negative || compiledNegOutput;
|
||||
if (neg) result += `\n\n<span class="pcr-output-panel-label-neg">Negative:</span>\n${escapeHtml(neg)}`;
|
||||
return result;
|
||||
}
|
||||
let result = `<span class="pcr-output-panel-label-pos">Positive:</span>\n${escapeHtml(compiledOutput)}`;
|
||||
if (compiledNegOutput) {
|
||||
result += `\n\n<span class="pcr-output-panel-label-neg">Negative:</span>\n${escapeHtml(compiledNegOutput)}`;
|
||||
@@ -56,6 +83,7 @@
|
||||
/* {@html} content — needs :global */
|
||||
:global(.pcr-output-panel-label-pos) { color: #4fc3f7; font-weight: 600; }
|
||||
:global(.pcr-output-panel-label-neg) { color: #e74c3c; font-weight: 600; }
|
||||
:global(.pcr-output-panel-label-region) { color: #ffb74d; font-weight: 600; }
|
||||
:global(.pcr-output-panel-placeholder) {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
|
||||
@@ -39,6 +39,7 @@ export class SharedState {
|
||||
// execution output
|
||||
compiledOutput = $state("");
|
||||
compiledNegOutput = $state("");
|
||||
compiledRegions = $state(""); // regions JSON for the region-aware output panel
|
||||
|
||||
// image preview
|
||||
imageUrl = $state(null);
|
||||
@@ -65,6 +66,7 @@ export class SharedState {
|
||||
this.regionPanelVisible = !!p.pcrRegionPanel;
|
||||
this.compiledOutput = p.pcrCompiledOutput || "";
|
||||
this.compiledNegOutput = p.pcrCompiledNegOutput || "";
|
||||
this.compiledRegions = p.pcrCompiledRegions || "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -29,7 +29,9 @@ class DepthAnythingV2Detector:
|
||||
pretrained_model_or_path = DEPTH_ANYTHING_V2_MODEL_NAME_DICT[filename]
|
||||
model_path = custom_hf_download(pretrained_model_or_path, filename)
|
||||
model = DepthAnythingV2(**model_configs[filename])
|
||||
model.load_state_dict(torch.load(model_path, map_location="cpu"))
|
||||
# weights_only: these checkpoints are pure state_dicts, so refuse the
|
||||
# legacy pickle path that can execute arbitrary code at load time.
|
||||
model.load_state_dict(torch.load(model_path, map_location="cpu", weights_only=True))
|
||||
model = model.eval()
|
||||
return cls(model, filename)
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -24,7 +24,8 @@ class TEDDetector:
|
||||
def from_pretrained(cls, pretrained_model_or_path=BDS_MODEL_NAME, filename="7_model.pth", subfolder="Annotators"):
|
||||
model_path = custom_hf_download(pretrained_model_or_path, filename, subfolder=subfolder)
|
||||
model = TED()
|
||||
model.load_state_dict(torch.load(model_path, map_location='cpu'))
|
||||
# weights_only: pure state_dict — refuse the code-executing legacy pickle path.
|
||||
model.load_state_dict(torch.load(model_path, map_location='cpu', weights_only=True))
|
||||
model.eval()
|
||||
return cls(model)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user