diff --git a/README.md b/README.md index aa6e99b..475552d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/caption_format_route.py b/caption_format_route.py index 49199c3..19f5bbf 100644 --- a/caption_format_route.py +++ b/caption_format_route.py @@ -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, diff --git a/caption_metadata_routes.py b/caption_metadata_routes.py index b21e312..6df922e 100644 --- a/caption_metadata_routes.py +++ b/caption_metadata_routes.py @@ -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: - parts.append("The softcore version keeps the same adult cast present together in a non-explicit teaser setup") + 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)") diff --git a/caption_naturalizer.py b/caption_naturalizer.py index 066972d..6bc8e6e 100644 --- a/caption_naturalizer.py +++ b/caption_naturalizer.py @@ -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, diff --git a/caption_text_policy.py b/caption_text_policy.py index 41d2f89..c4cfd0a 100644 --- a/caption_text_policy.py +++ b/caption_text_policy.py @@ -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, diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index a126b1b..316337f 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -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 diff --git a/node_formatter.py b/node_formatter.py index ec4775e..6cf1e62 100644 --- a/node_formatter.py +++ b/node_formatter.py @@ -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, diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 8b634ea..445c388 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -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(