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:
chrmc40
2026-06-28 20:49:04 -07:00
parent e4b970d2b6
commit 9fd6498ba6
30 changed files with 1246 additions and 193 deletions
+15 -4
View File
@@ -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
View File
@@ -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()
+29
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": "Regional — Region Boxes",
"name": "Region Boxes (caption placement)",
"category": "Generation",
"order": 1,
"scope": {
+4 -4
View File
@@ -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);})();
+24 -16
View File
@@ -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
+17 -6
View File
@@ -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;
+50 -3
View File
@@ -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 = {
"&": "&amp;",
"<": "&lt;",
@@ -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();
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+22 -8
View File
@@ -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:
+2 -2
View File
@@ -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
View File
@@ -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)
+30 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+272
View File
@@ -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()
+94
View File
@@ -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())
+124
View File
@@ -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())
+154
View File
@@ -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 -->
+30 -2
View File
@@ -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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
@@ -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;
+2
View File
@@ -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
View File
@@ -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)
+2 -1
View File
@@ -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)