Add caption pair target routing

This commit is contained in:
2026-06-27 13:06:26 +02:00
parent 58f74e44e5
commit 616d1132ff
8 changed files with 121 additions and 25 deletions
+4
View File
@@ -448,10 +448,14 @@ single combined natural caption with the shared descriptor plus separate
softcore and hardcore version descriptions. It uses the final selected
expression and composition from the generated rows, including any expression
pool and intensity settings.
Set `target=softcore` or `target=hardcore` to emit only one side of the pair for
training captions or formatter chains.
Naturalizer controls:
- `input_hint`: `auto`, `metadata_json`, or `caption_or_prompt`.
- `target`: `auto` keeps the combined pair caption; `single`, `softcore`, and
`hardcore` mirror the formatter target controls.
- `caption_profile`: `manual_controls` keeps the detail/style/trigger widgets
authoritative; `training_concise`, `training_dense`, and `browsing` apply
preset caption behavior.
+7 -2
View File
@@ -9,6 +9,7 @@ class CaptionFormatRequest:
source_text: str
metadata_json: str = ""
input_hint: str = "auto"
target: str = "auto"
trigger: str = ""
include_trigger: bool = True
detail_level: str = "balanced"
@@ -22,6 +23,7 @@ class CaptionFormatRoute:
method: str
branch: str
input_hint: str
target: str
detail_level: str
style_policy: str
include_trigger: bool
@@ -36,7 +38,7 @@ class CaptionFormatDependencies:
apply_caption_profile: Callable[[str, str, str, bool], tuple[str, str, bool]]
keep_style_terms: Callable[[str], bool]
row_from_inputs: Callable[[str, str, str], tuple[dict[str, Any] | None, str]]
metadata_to_prose: Callable[[dict[str, Any], str, bool], tuple[str, str]]
metadata_to_prose: Callable[..., tuple[str, str]]
text_to_prose: Callable[[str, str, bool], tuple[str, str]]
with_trigger: Callable[[str, str, bool], str]
sanitize_prose_text: Callable[..., str]
@@ -47,6 +49,7 @@ def naturalize_caption_result(
deps: CaptionFormatDependencies,
) -> CaptionFormatRoute:
input_hint = request.input_hint if request.input_hint in ("auto", "metadata_json", "caption_or_prompt") else "auto"
target = request.target if request.target in ("auto", "single", "softcore", "hardcore") else "auto"
detail_level, style_policy, include_trigger = deps.apply_caption_profile(
request.caption_profile,
request.detail_level,
@@ -56,7 +59,7 @@ def naturalize_caption_result(
keep_style = deps.keep_style_terms(style_policy)
row, row_method = deps.row_from_inputs(request.source_text, request.metadata_json, input_hint)
if row is not None:
prose, method = deps.metadata_to_prose(row, detail_level, keep_style)
prose, method = deps.metadata_to_prose(row, detail_level, keep_style, target)
caption = deps.sanitize_prose_text(
deps.with_trigger(prose, request.trigger, include_trigger),
triggers=(request.trigger,),
@@ -67,6 +70,7 @@ def naturalize_caption_result(
method=full_method,
branch="metadata",
input_hint=input_hint,
target=target,
detail_level=detail_level,
style_policy=style_policy,
include_trigger=include_trigger,
@@ -83,6 +87,7 @@ def naturalize_caption_result(
method=method,
branch="text",
input_hint=input_hint,
target=target,
detail_level=detail_level,
style_policy=style_policy,
include_trigger=include_trigger,
+17 -6
View File
@@ -10,6 +10,7 @@ class CaptionMetadataRouteRequest:
row: dict[str, Any]
detail_level: str
keep_style: bool
target: str = "auto"
@dataclass(frozen=True)
@@ -46,7 +47,7 @@ class CaptionMetadataRouteDependencies:
natural_cast_descriptor_text: Callable[[str], str]
cast_labels: Callable[[str], list[str]]
natural_label_text: Callable[[Any, list[str]], str]
metadata_to_prose: Callable[[dict[str, Any], str, bool], tuple[str, str]]
metadata_to_prose: Callable[..., tuple[str, str]]
def pronoun(subject: str) -> str:
@@ -300,6 +301,7 @@ def insta_of_pair_from_row_result(
row = request.row
detail_level = request.detail_level
keep_style = request.keep_style
target = request.target if request.target in ("softcore", "hardcore") else "auto"
if deps.clean_text(row.get("mode")).lower() != "insta/of":
return None
soft_row = row.get("softcore_row")
@@ -315,8 +317,14 @@ def insta_of_pair_from_row_result(
if soft_row.get("composition"):
hard_row_for_text["composition"] = soft_row["composition"]
soft_text, _soft_method = deps.metadata_to_prose(soft_row, detail_level, keep_style)
hard_text, _hard_method = deps.metadata_to_prose(hard_row_for_text, detail_level, keep_style)
include_soft = target in ("auto", "softcore")
include_hard = target in ("auto", "hardcore")
soft_text = ""
hard_text = ""
if include_soft:
soft_text, _soft_method = deps.metadata_to_prose(soft_row, detail_level, keep_style, "single")
if include_hard:
hard_text, _hard_method = deps.metadata_to_prose(hard_row_for_text, detail_level, keep_style, "single")
descriptor = deps.clean_text(row.get("shared_descriptor"))
options = row.get("options") if isinstance(row.get("options"), dict) else {}
cast_descriptors = row.get("shared_cast_descriptors")
@@ -335,8 +343,11 @@ def insta_of_pair_from_row_result(
parts.append(f"A {descriptor}")
if cast_descriptor_text and not same_soft_cast:
parts.append(deps.natural_cast_descriptor_text(cast_descriptor_text))
if same_soft_cast:
if same_soft_cast and include_soft:
if target == "auto":
parts.append("The softcore version keeps the same adult cast present together in a non-explicit teaser setup")
else:
parts.append("The same adult cast is present together in a non-explicit teaser setup")
partner_styling = row.get("softcore_partner_styling")
if isinstance(partner_styling, dict):
outfits = partner_styling.get("outfits")
@@ -349,9 +360,9 @@ def insta_of_pair_from_row_result(
if pose:
parts.append(f"The shared softcore cast pose is {pose}")
if soft_text:
parts.append(f"Softcore version: {soft_text}")
parts.append(f"Softcore version: {soft_text}" if target == "auto" else soft_text)
if hard_text:
parts.append(f"Hardcore version: {hard_text}")
parts.append(f"Hardcore version: {hard_text}" if target == "auto" else hard_text)
if not parts:
return None
return CaptionMetadataRoute(deps.join_sentences(parts), "metadata(insta_of_pair)")
+46 -12
View File
@@ -172,17 +172,24 @@ def _caption_metadata_route_request(
row: dict[str, Any],
detail_level: str,
keep_style: bool,
target: str = "auto",
) -> caption_metadata_routes.CaptionMetadataRouteRequest:
return caption_metadata_routes.CaptionMetadataRouteRequest(
row=row,
detail_level=detail_level,
keep_style=keep_style,
target=target,
)
def _single_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
def _single_from_row(
row: dict[str, Any],
detail_level: str,
keep_style: bool,
target: str = "auto",
) -> tuple[str, str] | None:
return caption_metadata_routes.single_from_row(
_caption_metadata_route_request(row, detail_level, keep_style),
_caption_metadata_route_request(row, detail_level, keep_style, target),
_caption_metadata_route_dependencies(),
)
@@ -199,35 +206,60 @@ def _couple_clothing_sentence(clothing: str) -> str:
return caption_metadata_routes.couple_clothing_sentence(clothing, _clean_text)
def _couple_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
def _couple_from_row(
row: dict[str, Any],
detail_level: str,
keep_style: bool,
target: str = "auto",
) -> tuple[str, str] | None:
return caption_metadata_routes.couple_from_row(
_caption_metadata_route_request(row, detail_level, keep_style),
_caption_metadata_route_request(row, detail_level, keep_style, target),
_caption_metadata_route_dependencies(),
)
def _configured_cast_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
def _configured_cast_from_row(
row: dict[str, Any],
detail_level: str,
keep_style: bool,
target: str = "auto",
) -> tuple[str, str] | None:
return caption_metadata_routes.configured_cast_from_row(
_caption_metadata_route_request(row, detail_level, keep_style),
_caption_metadata_route_request(row, detail_level, keep_style, target),
_caption_metadata_route_dependencies(),
)
def _group_or_layout_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
def _group_or_layout_from_row(
row: dict[str, Any],
detail_level: str,
keep_style: bool,
target: str = "auto",
) -> tuple[str, str] | None:
return caption_metadata_routes.group_or_layout_from_row(
_caption_metadata_route_request(row, detail_level, keep_style),
_caption_metadata_route_request(row, detail_level, keep_style, target),
_caption_metadata_route_dependencies(),
)
def _insta_of_pair_from_row(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str] | None:
def _insta_of_pair_from_row(
row: dict[str, Any],
detail_level: str,
keep_style: bool,
target: str = "auto",
) -> tuple[str, str] | None:
return caption_metadata_routes.insta_of_pair_from_row(
_caption_metadata_route_request(row, detail_level, keep_style),
_caption_metadata_route_request(row, detail_level, keep_style, target),
_caption_metadata_route_dependencies(),
)
def _metadata_to_prose(row: dict[str, Any], detail_level: str, keep_style: bool) -> tuple[str, str]:
def _metadata_to_prose(
row: dict[str, Any],
detail_level: str,
keep_style: bool,
target: str = "auto",
) -> tuple[str, str]:
for builder in (
_insta_of_pair_from_row,
_configured_cast_from_row,
@@ -235,7 +267,7 @@ def _metadata_to_prose(row: dict[str, Any], detail_level: str, keep_style: bool)
_couple_from_row,
_group_or_layout_from_row,
):
result = builder(row, detail_level, keep_style)
result = builder(row, detail_level, keep_style, target)
if result:
prose, method = result
return _append_formatter_hints(prose, row), method
@@ -346,6 +378,7 @@ def naturalize_caption(
detail_level: str = "balanced",
style_policy: str = "drop_style_tail",
caption_profile: str = caption_policy.CAPTION_PROFILE_DEFAULT,
target: str = "auto",
) -> tuple[str, str]:
"""Rewrite tag-style prompt/caption text into compact natural language."""
return caption_format_route.naturalize_caption(
@@ -353,6 +386,7 @@ def naturalize_caption(
source_text=source_text,
metadata_json=metadata_json,
input_hint=input_hint,
target=target,
trigger=trigger,
include_trigger=include_trigger,
detail_level=detail_level,
+1 -1
View File
@@ -274,7 +274,7 @@ def detail_allows(level: str, dense_only: bool = False) -> bool:
def metadata_route_dependencies(
metadata_to_prose: Callable[[dict[str, Any], str, bool], tuple[str, str]],
metadata_to_prose: Callable[..., tuple[str, str]],
) -> caption_metadata_routes.CaptionMetadataRouteDependencies:
return caption_metadata_routes.CaptionMetadataRouteDependencies(
item_labels=ITEM_LABELS,
+4 -3
View File
@@ -132,7 +132,7 @@ Core helper ownership:
| `sdxl_presets.py` | SDXL formatter profiles, style presets, quality presets, default negative prompt, and metadata-family tag hints used by the SDXL formatter and node choice lists. |
| `sdxl_format_route.py` | Top-level SDXL dispatch, formatter profile application, target and nude-weight normalization, metadata-vs-text input selection, single-vs-pair branching, final prompt/negative output shape, and fallback routing. |
| `sdxl_tag_policy.py` | SDXL tag splitting, tag-key dedupe, count inference, character descriptor tags, metadata-family/camera/explicit helper tags, and route dependency assembly used by `sdxl_formatter.py` and `sdxl_tag_routes.py`. |
| `caption_format_route.py` | Top-level caption dispatch, input-hint normalization, caption profile application, metadata-vs-text branching, trigger wrapping, final prose hygiene, and method/output shape. |
| `caption_format_route.py` | Top-level caption dispatch, input-hint and target normalization, caption profile application, metadata-vs-text branching, trigger wrapping, final prose hygiene, and method/output shape. |
| `caption_policy.py` | Caption naturalizer policy data and helpers: caption profiles, style tails, item labels, metadata-family caption labels, detail/style-policy normalization, clothing cleanup, and composition cleanup. |
| `caption_text_policy.py` | Caption sentence helpers, trigger wrapping, formatter-hint append, row-value fallback wrappers, cast text wrappers, single-caption front parsing, and metadata-route dependency assembly used by `caption_naturalizer.py` and `caption_metadata_routes.py`. |
@@ -150,7 +150,7 @@ recoverable.
| `SxCP Insta/OF Prompt Pair` | options, seed_config, character_cast, location/composition/camera, hardcore_position_config | `softcore_prompt`, `hardcore_prompt`, both negatives, both captions, `shared_descriptor`, `metadata_json` |
| `SxCP Krea2 Formatter` | `source_text`, connectable `metadata_json`, target | `krea_prompt`, both pair prompts if pair metadata exists, negative outputs, method |
| `SxCP SDXL Formatter` | `source_text`, connectable `metadata_json`, target, style/quality preset | `sdxl_prompt`, both pair prompts if pair metadata exists, negative outputs, method |
| `SxCP Caption Naturalizer` | `source_text`, connectable `metadata_json` | `natural_caption`, method |
| `SxCP Caption Naturalizer` | `source_text`, connectable `metadata_json`, target | `natural_caption`, method |
## Practical Recipes
@@ -170,6 +170,7 @@ These recipes identify the intended road before editing prompt text.
| Use Qwen/orbit camera geometry | Qwen/orbit node -> camera_config -> builder/pair | For pair, use `softcore_camera_config` and/or `hardcore_camera_config`; set mode from config in options | `_camera_config_with_mode`, `_camera_directive`, `_camera_scene_directive_for_context` |
| Use Krea2 for only hard prompt from a pair | Pair `metadata_json` -> Krea2 Formatter | `target=hardcore`, `input_hint=metadata_json` or auto with metadata connected | `_insta_pair_to_krea`, hard row fields |
| Convert builder output to SDXL tags | Builder/pair metadata -> SDXL Formatter | Use metadata input; set `target`; select style and quality preset | `sdxl_tag_routes.py`, `sdxl_tag_policy.py`, compatibility wrappers `_row_core_tags` / `_soft_tags` / `_hard_tags` |
| Convert pair metadata to one training caption side | Pair `metadata_json` -> Caption Naturalizer | `target=softcore` or `target=hardcore`; use `training_concise` or `training_dense` as needed | `caption_format_route.py`, `caption_metadata_routes.insta_of_pair_from_row_result` |
| Save/reuse character | Slot/profile nodes -> Profile Save/Load -> slot/builder | Save from the row/profile data you want, not a freshly randomized disconnected route | `character_profile.py`, `web/profile_buttons.js`, profile JSON |
## Seed Axes
@@ -757,7 +758,7 @@ Naturalizer field consumption:
| --- | --- | --- |
| Normal single/couple/group | subject fields, age/body, item, scene, expression, composition, camera scene | `caption_metadata_routes.single_from_row_result`, `caption_metadata_routes.couple_from_row_result`, `caption_metadata_routes.group_or_layout_from_row_result` |
| Configured cast/hardcore | `cast_descriptor_text`, `action_family`, `position_family`, `role_graph`, `item`, `scene_text`, expression, composition | `caption_metadata_routes.configured_cast_from_row_result`, `caption_text_policy.metadata_action_label` |
| Insta/OF pair | `softcore_row`, `hardcore_row`, pair options and continuity | `caption_metadata_routes.insta_of_pair_from_row_result` |
| Insta/OF pair | `softcore_row`, `hardcore_row`, pair options and continuity, target | `caption_metadata_routes.insta_of_pair_from_row_result` |
| Text fallback | `caption` or `prompt` text | `caption_naturalizer._text_to_prose`, with sentence helpers delegated to `caption_text_policy.py` |
### Final Text Hygiene
+3
View File
@@ -34,6 +34,7 @@ class SxCPCaptionNaturalizer:
"style_policy": (["drop_style_tail", "keep_style_terms"], {"default": "drop_style_tail"}),
"trigger": ("STRING", {"default": "sxcppnl7"}),
"include_trigger": ("BOOLEAN", {"default": True}),
"target": (["auto", "single", "softcore", "hardcore"], {"default": "auto"}),
},
"optional": {
"source_text_input": ("STRING", {"forceInput": True}),
@@ -55,6 +56,7 @@ class SxCPCaptionNaturalizer:
style_policy,
trigger,
include_trigger,
target="auto",
source_text_input="",
metadata_json="",
):
@@ -63,6 +65,7 @@ class SxCPCaptionNaturalizer:
source_text=active_source_text,
metadata_json=metadata_json or "",
input_hint=input_hint,
target=target,
trigger=trigger,
include_trigger=include_trigger,
detail_level=detail_level,
+38
View File
@@ -188,6 +188,7 @@ def _expect_formatter_outputs(row: dict[str, Any], name: str, *, target: str = "
caption, method = caption_naturalizer.naturalize_caption(
"",
metadata_json=metadata,
target=target,
trigger=Trigger,
include_trigger=True,
)
@@ -2688,6 +2689,7 @@ def smoke_caption_format_route_policy() -> None:
source_text="",
metadata_json=_json(row),
input_hint="metadata_json",
target="single",
trigger=Trigger,
include_trigger=False,
detail_level="concise",
@@ -2702,6 +2704,7 @@ def smoke_caption_format_route_policy() -> None:
"",
metadata_json=metadata_request.metadata_json,
input_hint=metadata_request.input_hint,
target=metadata_request.target,
trigger=metadata_request.trigger,
include_trigger=metadata_request.include_trigger,
detail_level=metadata_request.detail_level,
@@ -2711,6 +2714,7 @@ def smoke_caption_format_route_policy() -> None:
_expect(typed_metadata.as_tuple() == public_metadata, "Typed caption format route should match public metadata output")
_expect(typed_metadata.branch == "metadata", "Typed caption format route changed metadata branch")
_expect(typed_metadata.input_hint == "metadata_json", "Typed caption route lost input hint")
_expect(typed_metadata.target == "single", "Typed caption route lost target normalization")
_expect(typed_metadata.detail_level == "dense", "Typed caption route lost training_dense detail override")
_expect(typed_metadata.style_policy == "drop_style_tail", "Typed caption route lost training_dense style override")
_expect(typed_metadata.include_trigger is True, "Typed caption route lost training_dense trigger override")
@@ -2719,6 +2723,7 @@ def smoke_caption_format_route_policy() -> None:
fallback_request = caption_format_route.CaptionFormatRequest(
source_text="woman, red dress, studio, coloured pencil comic illustration",
input_hint="bad_hint",
target="weird",
trigger=Trigger,
include_trigger=True,
detail_level="dense",
@@ -2732,6 +2737,7 @@ def smoke_caption_format_route_policy() -> None:
public_fallback = caption_naturalizer.naturalize_caption(
fallback_request.source_text,
input_hint=fallback_request.input_hint,
target=fallback_request.target,
trigger=fallback_request.trigger,
include_trigger=fallback_request.include_trigger,
detail_level=fallback_request.detail_level,
@@ -2741,6 +2747,7 @@ def smoke_caption_format_route_policy() -> None:
_expect(typed_fallback.as_tuple() == public_fallback, "Typed caption format route should match public fallback output")
_expect(typed_fallback.branch == "text", "Typed caption format route changed fallback branch")
_expect(typed_fallback.input_hint == "auto", "Typed caption route should normalize invalid input hint")
_expect(typed_fallback.target == "auto", "Typed caption route should normalize invalid target")
_expect(typed_fallback.include_trigger is False, "Typed caption browsing profile should disable trigger")
_expect(typed_fallback.keep_style is True, "Typed caption browsing profile should keep style terms")
_expect(not typed_fallback.caption.startswith(Trigger), "Typed caption fallback route should not prepend browsing trigger")
@@ -2898,6 +2905,34 @@ def smoke_caption_metadata_routes() -> None:
caption_naturalizer._insta_of_pair_from_row,
"metadata(insta_of_pair)",
)
deps = caption_naturalizer._caption_metadata_route_dependencies()
soft_route = caption_metadata_routes.insta_of_pair_from_row_result(
caption_naturalizer._caption_metadata_route_request(pair, "balanced", False, target="softcore"),
deps,
)
hard_route = caption_metadata_routes.insta_of_pair_from_row_result(
caption_naturalizer._caption_metadata_route_request(pair, "balanced", False, target="hardcore"),
deps,
)
_expect(soft_route is not None, "Caption pair softcore target did not match")
_expect(hard_route is not None, "Caption pair hardcore target did not match")
assert soft_route is not None
assert hard_route is not None
_expect("Softcore version:" not in soft_route.prose, "Caption softcore target should not keep combined pair labels")
_expect("Hardcore version:" not in soft_route.prose, "Caption softcore target should not include hard label")
_expect("Softcore version:" not in hard_route.prose, "Caption hardcore target should not include soft label")
_expect("Hardcore version:" not in hard_route.prose, "Caption hardcore target should not keep combined pair labels")
_expect(soft_route.prose != hard_route.prose, "Caption pair soft/hard targets should produce distinct prose")
public_hard, public_hard_method = caption_naturalizer.naturalize_caption(
"",
metadata_json=_json(pair),
input_hint="metadata_json",
target="hardcore",
trigger=Trigger,
include_trigger=False,
)
_expect(public_hard == hard_route.prose, "Public caption hardcore target drifted from typed route")
_expect("metadata(insta_of_pair)" in public_hard_method, "Public caption hardcore target lost pair method")
def smoke_sdxl_presets_policy() -> None:
@@ -5854,6 +5889,7 @@ def smoke_node_formatter_registration() -> None:
_expect("text(" in caption_method, "Caption Naturalizer method changed unexpectedly")
caption_inputs = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPCaptionNaturalizer"].INPUT_TYPES().get("required") or {}
_expect("caption_profile" in caption_inputs, "Caption Naturalizer lost caption_profile input")
_expect("target" in caption_inputs, "Caption Naturalizer lost target input")
_expect("tooltip" in caption_inputs["caption_profile"][1], "Caption profile tooltip injection missing")
krea_output = krea_node().build(
@@ -5987,6 +6023,7 @@ def smoke_node_formatter_registration() -> None:
"",
metadata_json=pair_metadata,
input_hint="metadata_json",
target="hardcore",
trigger=Trigger,
include_trigger=True,
detail_level="balanced",
@@ -6001,6 +6038,7 @@ def smoke_node_formatter_registration() -> None:
"drop_style_tail",
Trigger,
True,
"hardcore",
metadata_json=pair_metadata,
)
_expect(