diff --git a/README.md b/README.md index 3670175..d3accd4 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,14 @@ node. For cleaner workflows, use the split nodes: `men_weights=0.5,0.3` means 50% no man and 30% one man. - `SxCP Location Pool` outputs `location_config`. `replace` uses only the selected/custom location pool; `add` keeps the category's own locations and - adds yours. Custom lines can be plain location text, or `slug: location text`. + adds yours. Custom lines can be plain location text, `slug: location text`, or + one-line JSON objects/arrays. JSON location entries preserve metadata such as + inline `camera_profile` / `scene_camera_profile`. - `SxCP Composition Pool` outputs `composition_config` to control framing separately from location. Use it when category framing mentions unrelated - outfit-check details such as shoes, bags, or mirror poses. + outfit-check details such as shoes, bags, or mirror poses. Custom composition + lines can also be one-line JSON objects/arrays when metadata needs to travel + with the selected composition. - `SxCP Location Theme` outputs matched `location_config` and `composition_config`. Themes such as `classical_library`, `semi_public_affair`, `hotel_corridor`, `parking_garage`, and diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 0e020db..04dafef 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -325,6 +325,10 @@ Edit targets: - Add reusable named locations: `categories/location_pools.json`. - Add category-specific locations: the category JSON file. - Add quick workflow-only locations: `SxCP Location Pool` custom locations. + Plain text, `slug: text`, one-line JSON objects, and one-line JSON arrays are + supported; JSON entries preserve metadata such as inline `camera_profile`. +- Add quick workflow-only compositions: `SxCP Composition Pool` custom + compositions. Plain text and one-line JSON objects/arrays are supported. - Add themed location packs: `THEMATIC_LOCATION_PRESETS` in `location_config.py`. ### Expression diff --git a/location_config.py b/location_config.py index c4e40ee..c4a65cf 100644 --- a/location_config.py +++ b/location_config.py @@ -268,12 +268,57 @@ def location_pool_names_for_preset(preset: str) -> list[str]: return names -def custom_location_entries(custom_locations: str) -> list[dict[str, str]]: - entries: list[dict[str, str]] = [] +def entry_prompt_text(value: Any) -> str: + if isinstance(value, dict): + return str( + value.get("prompt") + or value.get("template") + or value.get("text") + or value.get("description") + or value.get("name") + or "" + ).strip() + return str(value or "").strip() + + +def json_line_entries(line: str, field_name: str) -> list[Any] | None: + line = line.strip() + if not line or line[0] not in "[{": + return None + try: + parsed = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON line in {field_name}: {exc}") from exc + if isinstance(parsed, list): + return parsed + return [parsed] + + +def normalize_custom_location_entry(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + entry = dict(value) + prompt = entry_prompt_text(entry) + if not prompt: + raise ValueError(f"Custom location JSON entry is missing prompt/text/description/name: {value!r}") + entry["slug"] = _slug(str(entry.get("slug") or entry.get("name") or prompt)) + entry["prompt"] = prompt + return entry + prompt = str(value or "").strip() + if not prompt: + raise ValueError("Custom location entry cannot be empty") + return {"slug": _slug(prompt), "prompt": prompt} + + +def custom_location_entries(custom_locations: str) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] for raw_line in str(custom_locations or "").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue + json_entries = json_line_entries(line, "custom_locations") + if json_entries is not None: + entries.extend(normalize_custom_location_entry(entry) for entry in json_entries) + continue slug = "" prompt = line if ":" in line: @@ -389,12 +434,30 @@ def composition_pool_names_for_preset(preset: str) -> list[str]: return names -def custom_composition_entries(custom_compositions: str) -> list[str]: - entries: list[str] = [] +def normalize_custom_composition_entry(value: Any) -> Any: + if isinstance(value, dict): + entry = dict(value) + prompt = entry_prompt_text(entry) + if not prompt: + raise ValueError(f"Custom composition JSON entry is missing prompt/text/description/name: {value!r}") + entry["prompt"] = prompt + return entry + text = str(value or "").strip() + if not text: + raise ValueError("Custom composition entry cannot be empty") + return text + + +def custom_composition_entries(custom_compositions: str) -> list[Any]: + entries: list[Any] = [] for raw_line in str(custom_compositions or "").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue + json_entries = json_line_entries(line, "custom_compositions") + if json_entries is not None: + entries.extend(normalize_custom_composition_entry(entry) for entry in json_entries) + continue entries.append(line) return entries diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index f16e4d8..722d2f8 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -521,6 +521,69 @@ def smoke_camera_scene_single() -> None: _expect("45-degree front-right quarter view" in prompt, "Krea single prompt lost camera directive") _expect_formatter_outputs(row, "camera_scene_single", target="single") + custom_location = pb.build_location_pool_json( + enabled=True, + combine_mode="replace", + preset="custom_only", + custom_locations=json.dumps( + { + "slug": "greenhouse_suite", + "prompt": "private room with soft daylight", + "camera_profile": { + "key": "glass_conservatory", + "family": "greenhouse", + "layout_label": "Glass conservatory camera layout", + "place": "glass conservatory", + "foreground": "plant shelf edge, fern leaves, and iron table corner", + "midground": "glass panes, iron ribs, and potted palms", + "background": "hanging vines, greenhouse windows, and layered plant depth", + "detail_label": "conservatory details", + "composition": { + "woman": "glass conservatory frame with the woman beside fern leaves and greenhouse depth behind her", + "default": "glass conservatory frame with the subjects beside fern leaves and greenhouse depth behind them", + }, + }, + }, + sort_keys=True, + ), + ) + custom_composition = pb.build_composition_pool_json( + enabled=True, + combine_mode="replace", + preset="custom_only", + custom_compositions=json.dumps({"prompt": "polished mirror view with bag and shoes visible"}, sort_keys=True), + ) + custom_row = _prompt_row( + name="camera_scene_custom_inline_profile", + category="woman", + subcategory="random", + seed=1061, + men_count=0, + camera_config=_orbit_camera( + horizontal_angle=45, + vertical_angle=30, + zoom=5.0, + subject_focus="environment", + ), + location_config=custom_location, + composition_config=custom_composition, + ) + custom_scene = _expect_text( + "camera_scene_custom_inline_profile.camera_scene_directive", + custom_row.get("camera_scene_directive"), + 40, + ) + custom_composition_text = _expect_text( + "camera_scene_custom_inline_profile.composition", + custom_row.get("composition"), + 20, + ) + _expect("Glass conservatory camera layout" in custom_scene, "custom Location Pool JSON camera profile did not drive scene layout") + _expect(custom_row.get("scene_camera_profile_key") == "glass_conservatory", "custom Location Pool JSON profile key was not exposed") + _expect(custom_row.get("scene_entry", {}).get("camera_profile", {}).get("family") == "greenhouse", "custom Location Pool JSON profile metadata was not preserved") + _expect("glass conservatory" in custom_composition_text.lower(), "custom Location Pool JSON profile did not clean composition") + _expect("bag" not in custom_composition_text.lower() and "shoes" not in custom_composition_text.lower(), "custom inline profile composition leaked unrelated props") + def smoke_row_camera_policy() -> None: row = { @@ -1037,6 +1100,30 @@ def smoke_location_config_policy() -> None: _expect(custom.get("enabled") is True, "Custom location config should be active") _expect(custom.get("apply_mode") == "replace", "Custom location config lost replace mode") _expect(custom.get("scene_entries", [{}])[0].get("slug") == "custom_room", "Custom location slug parser changed") + structured_custom = json.loads( + pb.build_location_pool_json( + enabled=True, + combine_mode="replace", + preset="custom_only", + custom_locations=json.dumps( + { + "slug": "structured_room", + "text": "structured room with preserved metadata", + "camera_profile": { + "key": "structured_camera_profile", + "foreground": "foreground test anchor", + "midground": "middle test anchor", + "background": "background test anchor", + }, + }, + sort_keys=True, + ), + ) + ) + structured_entry = structured_custom.get("scene_entries", [{}])[0] + _expect(structured_entry.get("slug") == "structured_room", "Structured custom location lost slug") + _expect(structured_entry.get("prompt") == "structured room with preserved metadata", "Structured custom location did not normalize prompt text") + _expect(structured_entry.get("camera_profile", {}).get("key") == "structured_camera_profile", "Structured custom location lost camera profile metadata") added = json.loads( location_config.build_location_pool_json( @@ -1063,6 +1150,17 @@ def smoke_location_config_policy() -> None: any("outfit-check" in str(entry) for entry in composition.get("composition_entries") or []), "Composition inline preset no_outfit_check was not applied", ) + structured_composition = json.loads( + pb.build_composition_pool_json( + enabled=True, + combine_mode="replace", + preset="custom_only", + custom_compositions=json.dumps({"text": "structured composition frame", "source": "json_line"}, sort_keys=True), + ) + ) + structured_composition_entry = structured_composition.get("composition_entries", [{}])[0] + _expect(structured_composition_entry.get("prompt") == "structured composition frame", "Structured custom composition did not normalize prompt text") + _expect(structured_composition_entry.get("source") == "json_line", "Structured custom composition lost metadata") parsed = pb._parse_location_config({"enabled": True, "pool_names": [], "scene_entries": custom["scene_entries"]}) _expect(pb._location_config_active(parsed), "Prompt builder location parser wrapper is inactive")