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.
|
`men_weights=0.5,0.3` means 50% no man and 30% one man.
|
||||||
- `SxCP Location Pool` outputs `location_config`. `replace` uses only the
|
- `SxCP Location Pool` outputs `location_config`. `replace` uses only the
|
||||||
selected/custom location pool; `add` keeps the category's own locations and
|
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
|
- `SxCP Composition Pool` outputs `composition_config` to control framing
|
||||||
separately from location. Use it when category framing mentions unrelated
|
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
|
- `SxCP Location Theme` outputs matched `location_config` and
|
||||||
`composition_config`. Themes such as `classical_library`,
|
`composition_config`. Themes such as `classical_library`,
|
||||||
`semi_public_affair`, `hotel_corridor`, `parking_garage`, and
|
`semi_public_affair`, `hotel_corridor`, `parking_garage`, and
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ Edit targets:
|
|||||||
- Add reusable named locations: `categories/location_pools.json`.
|
- Add reusable named locations: `categories/location_pools.json`.
|
||||||
- Add category-specific locations: the category JSON file.
|
- Add category-specific locations: the category JSON file.
|
||||||
- Add quick workflow-only locations: `SxCP Location Pool` custom locations.
|
- 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`.
|
- Add themed location packs: `THEMATIC_LOCATION_PRESETS` in `location_config.py`.
|
||||||
|
|
||||||
### Expression
|
### Expression
|
||||||
|
|||||||
+67
-4
@@ -268,12 +268,57 @@ def location_pool_names_for_preset(preset: str) -> list[str]:
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def custom_location_entries(custom_locations: str) -> list[dict[str, str]]:
|
def entry_prompt_text(value: Any) -> str:
|
||||||
entries: list[dict[str, 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():
|
for raw_line in str(custom_locations or "").splitlines():
|
||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
if not line or line.startswith("#"):
|
if not line or line.startswith("#"):
|
||||||
continue
|
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 = ""
|
slug = ""
|
||||||
prompt = line
|
prompt = line
|
||||||
if ":" in line:
|
if ":" in line:
|
||||||
@@ -389,12 +434,30 @@ def composition_pool_names_for_preset(preset: str) -> list[str]:
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def custom_composition_entries(custom_compositions: str) -> list[str]:
|
def normalize_custom_composition_entry(value: Any) -> Any:
|
||||||
entries: list[str] = []
|
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():
|
for raw_line in str(custom_compositions or "").splitlines():
|
||||||
line = raw_line.strip()
|
line = raw_line.strip()
|
||||||
if not line or line.startswith("#"):
|
if not line or line.startswith("#"):
|
||||||
continue
|
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)
|
entries.append(line)
|
||||||
return entries
|
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("45-degree front-right quarter view" in prompt, "Krea single prompt lost camera directive")
|
||||||
_expect_formatter_outputs(row, "camera_scene_single", target="single")
|
_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:
|
def smoke_row_camera_policy() -> None:
|
||||||
row = {
|
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("enabled") is True, "Custom location config should be active")
|
||||||
_expect(custom.get("apply_mode") == "replace", "Custom location config lost replace mode")
|
_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")
|
_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(
|
added = json.loads(
|
||||||
location_config.build_location_pool_json(
|
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 []),
|
any("outfit-check" in str(entry) for entry in composition.get("composition_entries") or []),
|
||||||
"Composition inline preset no_outfit_check was not applied",
|
"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"]})
|
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")
|
_expect(pb._location_config_active(parsed), "Prompt builder location parser wrapper is inactive")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user