Support structured custom location entries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+67
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user