Add camera orbit control

This commit is contained in:
2026-06-24 18:42:36 +02:00
parent b539d8c5f0
commit 6a5e71719e
4 changed files with 324 additions and 22 deletions
+35 -13
View File
@@ -9,6 +9,7 @@ The node is registered as:
- `prompt_builder / SxCP Seed Control`
- `prompt_builder / SxCP Seed Locker`
- `prompt_builder / SxCP Camera Control`
- `prompt_builder / SxCP Camera Orbit Control`
- `prompt_builder / SxCP Category Preset`
- `prompt_builder / SxCP Cast Control`
- `prompt_builder / SxCP Generation Profile`
@@ -54,8 +55,8 @@ node. For cleaner workflows, use the split nodes:
The practical compact workflow is:
`Category Preset` + `Cast Control` + `Generation Profile` + optional
`Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control`,
`Woman Slot` / `Man Slot`, and `Character Profile`
`Advanced Filters`, `Seed Locker` or `Seed Control`, `Camera Control` or
`Camera Orbit Control`, `Woman Slot` / `Man Slot`, and `Character Profile`
into `Prompt Builder From Configs`.
An importable default workflow is included at
@@ -178,9 +179,10 @@ you like, choose one `reroll_axis`, and connect its `seed_config`. All other
axes stay frozen to `base_seed`; the rerolled axis follows `reroll_seed`, or the
main prompt seed when `reroll_seed=-1`.
`SxCP Camera Control` outputs `camera_config`, which can be connected to the
prompt builder or the Insta/OF pair node. It makes camera/framing first-class
instead of relying on a weak phrase inside the prompt.
`SxCP Camera Control` and `SxCP Camera Orbit Control` output `camera_config`,
which can be connected to the prompt builder or the Insta/OF pair node. They
make camera/framing first-class instead of relying on a weak phrase inside the
prompt.
Camera controls:
@@ -201,6 +203,24 @@ Camera controls:
- `camera_detail`: `off` emits no camera sentence, `compact` emits one short
camera sentence, and `full` emits the full detailed camera constraint.
`SxCP Camera Orbit Control` is the numeric/directable version inspired by
multi-angle camera nodes. It maps `horizontal_angle`, `vertical_angle`, and
`zoom` into a stable prompt such as `135-degree back-right quarter view,
elevated shot, medium shot`. Use it when the model needs an exact front, side,
back-quarter, low, high, or overhead style camera anchor. Its first output is
the same `camera_config` type, so it can replace `SxCP Camera Control` anywhere.
Orbit controls:
- `horizontal_angle`: `0` front, `90` right side, `180` back, `270` left side,
with quarter views between them.
- `vertical_angle`: negative values are low-angle, `0` is eye-level, positive
values move toward elevated/high-angle.
- `zoom`: `0-2` wide, `2-6` medium, `6-10` close unless `framing` overrides it.
- `subject_focus`: optionally centers face, torso, hips, full body, main action,
contact points, or the environment.
- `include_degrees`: keeps the numeric angle in the emitted camera phrase.
`SxCP Caption Naturalizer` rewrites tag-like captions or labeled prompts into
more natural language. Connect the prompt builder's `metadata_json` output to
`source_text` for the cleanest result. You can also connect `caption` or
@@ -306,8 +326,8 @@ pair node. Defaults are set so the softcore prompt is solo while the hardcore
prompt can include partners. Softcore defaults to handheld selfie framing;
hardcore defaults to `from_camera_config`, which emits no camera sentence unless
a camera config is connected or you select an explicit hardcore camera mode.
For stronger camera control, connect `SxCP Camera Control` to the pair node's
optional `camera_config` input.
For stronger camera control, connect `SxCP Camera Control` or
`SxCP Camera Orbit Control` to the pair node's optional `camera_config` input.
Options:
@@ -342,11 +362,12 @@ Options:
references the softcore outfit, uses it displaced/removed, or makes Woman A
explicitly nude. It is a fallback for Woman A; `hardcore_clothing` on
`SxCP Woman Slot` or `SxCP Man Slot` takes priority for that character.
- `softcore_camera_mode`: base camera mode for the softcore output.
- `softcore_camera_mode`: `from_camera_config` or a base camera mode for the
softcore output. The default is still `handheld_selfie`.
- `hardcore_camera_mode`: `from_camera_config`, `same_as_softcore`, or a
separate base camera mode for the hardcore output. `from_camera_config` is
neutral with no connected camera config, and uses `SxCP Camera Control` when
one is connected.
neutral with no connected camera config, and uses `SxCP Camera Control` or
`SxCP Camera Orbit Control` when one is connected.
- `camera_detail`: `off`, `compact`, or `full` for the pair prompt camera text.
- `hardcore_detail_density`: `compact` keeps the Krea hardcore rewrite mostly
to the position/action sentence, `balanced` keeps one useful non-duplicated
@@ -376,9 +397,10 @@ The node keeps the original generator controls:
- `figure`: `curvy`, `balanced`, `bombshell`.
- In split workflows, use `SxCP Advanced Filters` checkboxes instead of negative
toggles. Black/African and plus-size are positive include choices there.
- Optional `camera_config`: connect `SxCP Camera Control` to force selfie,
phone, lens, angle, distance, crop, and camera-priority behavior. This applies
to custom categories too, including `Hardcore sexual poses`.
- Optional `camera_config`: connect `SxCP Camera Control` or
`SxCP Camera Orbit Control` to force selfie, phone, lens, angle, numeric
orbit, crop, and camera-priority behavior. This applies to custom categories
too, including `Hardcore sexual poses`.
`auto_weighted` uses the original batch mix: mostly women, then men, couples, and
group/layout rows. Direct categories generate only that selected category.
+71 -1
View File
@@ -6,6 +6,7 @@ import random
try:
from .prompt_builder import (
build_camera_config_json,
build_camera_orbit_config_json,
build_cast_config_json,
build_category_config_json,
build_character_slot_json,
@@ -23,6 +24,8 @@ try:
camera_distance_choices,
camera_lens_choices,
camera_mode_choices,
camera_orbit_focus_choices,
camera_orbit_framing_choices,
camera_orientation_choices,
camera_phone_choices,
camera_priority_choices,
@@ -52,6 +55,7 @@ try:
except ImportError:
from prompt_builder import (
build_camera_config_json,
build_camera_orbit_config_json,
build_cast_config_json,
build_category_config_json,
build_character_slot_json,
@@ -69,6 +73,8 @@ except ImportError:
camera_distance_choices,
camera_lens_choices,
camera_mode_choices,
camera_orbit_focus_choices,
camera_orbit_framing_choices,
camera_orientation_choices,
camera_phone_choices,
camera_priority_choices,
@@ -373,6 +379,68 @@ class SxCPCameraControl:
)
class SxCPCameraOrbitControl:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"enabled": ("BOOLEAN", {"default": True}),
"camera_mode": (camera_mode_choices(), {"default": "standard"}),
"horizontal_angle": ("INT", {"default": 0, "min": 0, "max": 359, "step": 1}),
"vertical_angle": ("INT", {"default": 0, "min": -90, "max": 90, "step": 1}),
"zoom": ("FLOAT", {"default": 5.0, "min": 0.0, "max": 10.0, "step": 0.1}),
"framing": (camera_orbit_framing_choices(), {"default": "from_zoom"}),
"subject_focus": (camera_orbit_focus_choices(), {"default": "auto"}),
"lens": (camera_lens_choices(), {"default": "auto"}),
"orientation": (camera_orientation_choices(), {"default": "auto"}),
"phone_visibility": (camera_phone_choices(), {"default": "auto"}),
"priority": (camera_priority_choices(), {"default": "locked"}),
"camera_detail": (camera_detail_choices(), {"default": "compact"}),
"include_degrees": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("camera_config", "camera_prompt", "camera_info_json")
FUNCTION = "build"
CATEGORY = "prompt_builder"
def build(
self,
enabled,
camera_mode,
horizontal_angle,
vertical_angle,
zoom,
framing,
subject_focus,
lens,
orientation,
phone_visibility,
priority,
camera_detail,
include_degrees,
):
config = build_camera_orbit_config_json(
enabled=enabled,
camera_mode=camera_mode,
horizontal_angle=horizontal_angle,
vertical_angle=vertical_angle,
zoom=zoom,
framing=framing,
subject_focus=subject_focus,
lens=lens,
orientation=orientation,
phone_visibility=phone_visibility,
priority=priority,
camera_detail=camera_detail,
include_degrees=include_degrees,
)
parsed = json.loads(config)
camera_prompt = parsed.get("custom_camera_prompt", "")
return config, camera_prompt, json.dumps(parsed, ensure_ascii=True, sort_keys=True)
class SxCPCategoryPreset:
@classmethod
def INPUT_TYPES(cls):
@@ -1083,7 +1151,7 @@ class SxCPInstaOFOptions:
"platform_style": (["hybrid", "instagram", "onlyfans"], {"default": "hybrid"}),
"continuity": (["same_creator_same_room", "same_creator_new_scene"], {"default": "same_creator_same_room"}),
"hardcore_clothing_continuity": (["none", "same_outfit", "partially_removed", "implied_nude", "explicit_nude"], {"default": "partially_removed"}),
"softcore_camera_mode": (camera_mode_choices(), {"default": "handheld_selfie"}),
"softcore_camera_mode": (["from_camera_config"] + camera_mode_choices(), {"default": "handheld_selfie"}),
"hardcore_camera_mode": (["from_camera_config", "same_as_softcore"] + camera_mode_choices(), {"default": "from_camera_config"}),
"camera_detail": (camera_detail_choices(), {"default": "compact"}),
"hardcore_detail_density": (hardcore_detail_density_choices(), {"default": "balanced"}),
@@ -1233,6 +1301,7 @@ NODE_CLASS_MAPPINGS = {
"SxCPSeedControl": SxCPSeedControl,
"SxCPSeedLocker": SxCPSeedLocker,
"SxCPCameraControl": SxCPCameraControl,
"SxCPCameraOrbitControl": SxCPCameraOrbitControl,
"SxCPCategoryPreset": SxCPCategoryPreset,
"SxCPCastControl": SxCPCastControl,
"SxCPGenerationProfile": SxCPGenerationProfile,
@@ -1254,6 +1323,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SxCPSeedControl": "SxCP Seed Control",
"SxCPSeedLocker": "SxCP Seed Locker",
"SxCPCameraControl": "SxCP Camera Control",
"SxCPCameraOrbitControl": "SxCP Camera Orbit Control",
"SxCPCategoryPreset": "SxCP Category Preset",
"SxCPCastControl": "SxCP Cast Control",
"SxCPGenerationProfile": "SxCP Generation Profile",
+10
View File
@@ -1320,6 +1320,11 @@ def _camera_phrase(row: dict[str, Any]) -> str:
detail = _clean(config.get("camera_detail"))
if detail == "off" or _clean(config.get("camera_mode")) == "disabled":
return ""
custom = _clean(config.get("custom_camera_prompt"))
if custom:
base = _clean(config.get("camera_mode")).replace("_", " ")
pieces = [piece for piece in (base, custom) if piece and piece != "standard"]
return "Camera: " + ", ".join(pieces)
mode = _clean(config.get("camera_mode")).replace("_", " ")
shot = _clean(config.get("shot_size")).replace("_", " ")
angle = _clean(config.get("angle")).replace("_", " ")
@@ -1335,6 +1340,11 @@ def _camera_phrase_from_config(config: Any) -> str:
detail = _clean(config.get("camera_detail"))
if detail == "off" or _clean(config.get("camera_mode")) == "disabled":
return ""
custom = _clean(config.get("custom_camera_prompt"))
if custom:
base = _clean(config.get("camera_mode")).replace("_", " ")
pieces = [piece for piece in (base, custom) if piece and piece != "standard"]
return "Camera: " + ", ".join(pieces)
values = [
_clean(config.get("camera_mode")).replace("_", " "),
_clean(config.get("shot_size")).replace("_", " "),
+208 -8
View File
@@ -180,6 +180,25 @@ CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "defau
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"]
CAMERA_ORBIT_FRAMING_CHOICES = [
"from_zoom",
"wide",
"medium",
"full_body",
"three_quarter",
"close_up",
"extreme_close_up",
]
CAMERA_ORBIT_FOCUS_CHOICES = [
"auto",
"face",
"torso",
"hips",
"full_body",
"action",
"contact_points",
"environment",
]
GENERIC_POSITIVE_SUFFIX = (
"Use crisp clean comic linework, detailed hatching, soft blended shading, "
@@ -1450,6 +1469,14 @@ def hardcore_detail_density_choices() -> list[str]:
return list(HARDCORE_DETAIL_DENSITY_CHOICES)
def camera_orbit_framing_choices() -> list[str]:
return list(CAMERA_ORBIT_FRAMING_CHOICES)
def camera_orbit_focus_choices() -> list[str]:
return list(CAMERA_ORBIT_FOCUS_CHOICES)
def camera_shot_choices() -> list[str]:
return list(CAMERA_SHOT_PROMPTS)
@@ -1506,12 +1533,145 @@ def build_camera_config_json(
)
def _camera_orbit_direction(horizontal_angle: Any) -> str:
h_angle = int(float(horizontal_angle or 0)) % 360
if h_angle < 22.5 or h_angle >= 337.5:
return "front view"
if h_angle < 67.5:
return "front-right quarter view"
if h_angle < 112.5:
return "right side view"
if h_angle < 157.5:
return "back-right quarter view"
if h_angle < 202.5:
return "back view"
if h_angle < 247.5:
return "back-left quarter view"
if h_angle < 292.5:
return "left side view"
return "front-left quarter view"
def _camera_orbit_elevation(vertical_angle: Any) -> str:
vertical = int(float(vertical_angle or 0))
if vertical < -15:
return "low-angle shot"
if vertical < 15:
return "eye-level shot"
if vertical < 45:
return "elevated shot"
return "high-angle shot"
def _camera_orbit_distance(zoom: Any, framing: str = "from_zoom") -> str:
framing = framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom"
framing_labels = {
"wide": "wide shot",
"medium": "medium shot",
"full_body": "full-body shot",
"three_quarter": "three-quarter body shot",
"close_up": "close-up",
"extreme_close_up": "extreme close-up",
}
if framing != "from_zoom":
return framing_labels[framing]
zoom_value = float(zoom or 0.0)
if zoom_value < 2:
return "wide shot"
if zoom_value < 6:
return "medium shot"
return "close-up"
def _camera_orbit_focus(subject_focus: str) -> str:
return {
"face": "face and expression centered",
"torso": "torso and hands centered",
"hips": "hips and lower body centered",
"full_body": "full body centered",
"action": "main action centered",
"contact_points": "body contact points centered",
"environment": "subject and room both readable",
}.get(str(subject_focus or "auto"), "")
def _camera_orbit_prompt(
horizontal_angle: Any,
vertical_angle: Any,
zoom: Any,
framing: str = "from_zoom",
subject_focus: str = "auto",
include_degrees: bool = True,
) -> tuple[str, dict[str, Any]]:
azimuth = max(0, min(359, int(float(horizontal_angle or 0))))
elevation = max(-90, min(90, int(float(vertical_angle or 0))))
zoom_value = max(0.0, min(10.0, float(zoom or 0.0)))
direction = _camera_orbit_direction(azimuth)
elevation_label = _camera_orbit_elevation(elevation)
distance_label = _camera_orbit_distance(zoom_value, framing)
focus_label = _camera_orbit_focus(subject_focus)
pieces = [direction, elevation_label, distance_label, focus_label]
prompt = ", ".join(piece for piece in pieces if piece)
if include_degrees:
prompt = f"{azimuth}-degree {prompt}"
return prompt, {
"orbit_azimuth": azimuth,
"orbit_elevation": elevation,
"orbit_zoom": zoom_value,
"orbit_direction": direction,
"orbit_elevation_label": elevation_label,
"orbit_distance_label": distance_label,
"orbit_framing": framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom",
"orbit_focus": subject_focus if subject_focus in CAMERA_ORBIT_FOCUS_CHOICES else "auto",
}
def build_camera_orbit_config_json(
enabled: bool = True,
camera_mode: str = "standard",
horizontal_angle: int = 0,
vertical_angle: int = 0,
zoom: float = 5.0,
framing: str = "from_zoom",
subject_focus: str = "auto",
lens: str = "auto",
orientation: str = "auto",
phone_visibility: str = "auto",
priority: str = "locked",
camera_detail: str = "compact",
include_degrees: bool = True,
) -> str:
orbit_prompt, orbit_metadata = _camera_orbit_prompt(
horizontal_angle,
vertical_angle,
zoom,
framing=framing,
subject_focus=subject_focus,
include_degrees=include_degrees,
)
config = {
"camera_mode": "disabled" if _is_false(enabled) else _choice(camera_mode, CAMERA_MODE_PROMPTS, "standard"),
"shot_size": "auto",
"angle": "auto",
"lens": _choice(lens, CAMERA_LENS_PROMPTS, "auto"),
"distance": "auto",
"orientation": _choice(orientation, CAMERA_ORIENTATION_PROMPTS, "auto"),
"phone_visibility": _choice(phone_visibility, CAMERA_PHONE_PROMPTS, "auto"),
"priority": _choice(priority, CAMERA_PRIORITY_PROMPTS, "locked"),
"camera_detail": camera_detail if camera_detail in CAMERA_DETAIL_CHOICES else "compact",
"camera_source": "orbit",
"custom_camera_prompt": orbit_prompt if not _is_false(enabled) else "",
**orbit_metadata,
}
return json.dumps(config, ensure_ascii=True, sort_keys=True)
def _choice(value: Any, choices: dict[str, str], default: str) -> str:
value = str(value or default)
return value if value in choices else default
def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, str]:
def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, Any]:
defaults = {
"camera_mode": "standard",
"shot_size": "auto",
@@ -1535,7 +1695,9 @@ def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str
if not isinstance(raw, dict):
raise ValueError("camera_config must be a JSON object")
parsed = {**defaults, **raw}
return {
custom_camera_prompt = _clean_prompt_punctuation(parsed.get("custom_camera_prompt", "")).rstrip(".")
camera_source = str(parsed.get("camera_source") or "")
normalized = {
"camera_mode": _choice(parsed.get("camera_mode"), CAMERA_MODE_PROMPTS, defaults["camera_mode"]),
"shot_size": _choice(parsed.get("shot_size"), CAMERA_SHOT_PROMPTS, defaults["shot_size"]),
"angle": _choice(parsed.get("angle"), CAMERA_ANGLE_PROMPTS, defaults["angle"]),
@@ -1548,19 +1710,37 @@ def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str
if str(parsed.get("camera_detail") or defaults["camera_detail"]) in CAMERA_DETAIL_CHOICES
else defaults["camera_detail"],
}
if custom_camera_prompt:
normalized["custom_camera_prompt"] = custom_camera_prompt
if camera_source:
normalized["camera_source"] = camera_source
for key in (
"orbit_azimuth",
"orbit_elevation",
"orbit_zoom",
"orbit_direction",
"orbit_elevation_label",
"orbit_distance_label",
"orbit_framing",
"orbit_focus",
):
if key in parsed:
normalized[key] = parsed[key]
return normalized
def _camera_config_with_mode(camera_config: str | dict[str, Any] | None, camera_mode: str) -> dict[str, str]:
def _camera_config_with_mode(camera_config: str | dict[str, Any] | None, camera_mode: str) -> dict[str, Any]:
parsed = _parse_camera_config(camera_config)
if camera_mode and camera_mode != "from_camera_config":
parsed["camera_mode"] = _choice(camera_mode, CAMERA_MODE_PROMPTS, parsed["camera_mode"])
return parsed
def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str, dict[str, str]]:
def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str, dict[str, Any]]:
parsed = _parse_camera_config(camera_config)
if parsed["camera_detail"] == "off" or parsed["camera_mode"] == "disabled":
return "", parsed
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
if parsed["camera_detail"] == "compact":
values = [
parsed["camera_mode"],
@@ -1573,6 +1753,8 @@ def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str,
]
labels = [CAMERA_COMPACT_LABELS.get(value, value.replace("_", " ")) for value in values]
labels = [label for value, label in zip(values, labels) if label and value != "auto"]
if custom_camera_prompt:
labels.append(custom_camera_prompt)
if not labels:
return "", parsed
directive = "Camera: " + ", ".join(labels) + "."
@@ -1588,6 +1770,8 @@ def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str,
CAMERA_ORIENTATION_PROMPTS[parsed["orientation"]],
CAMERA_PHONE_PROMPTS[parsed["phone_visibility"]],
]
if custom_camera_prompt:
parts.append(f"Camera orbit: {custom_camera_prompt}.")
parts = [part for part in parts if part]
if not parts:
return "", parsed
@@ -1603,6 +1787,16 @@ def _insert_positive_directive(prompt: str, directive: str) -> str:
return f"{prompt.rstrip()} {directive}"
def _camera_caption_text(parsed: dict[str, Any]) -> str:
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
if custom_camera_prompt:
return custom_camera_prompt
camera_mode = str(parsed.get("camera_mode") or "").replace("_", " ").strip()
if not camera_mode or camera_mode == "standard":
return ""
return f"{camera_mode} camera framing"
def _apply_camera_config(row: dict[str, Any], camera_config: str | dict[str, Any] | None) -> dict[str, Any]:
directive, parsed = _camera_directive(camera_config)
row["camera_config"] = parsed
@@ -1610,7 +1804,9 @@ def _apply_camera_config(row: dict[str, Any], camera_config: str | dict[str, Any
if not directive:
return row
row["prompt"] = _insert_positive_directive(row["prompt"], directive)
row["caption"] = f"{row.get('caption', '').rstrip()}, {parsed['camera_mode'].replace('_', ' ')} camera framing"
camera_caption = _camera_caption_text(parsed)
if camera_caption:
row["caption"] = f"{row.get('caption', '').rstrip()}, {camera_caption}"
return row
@@ -4117,7 +4313,11 @@ def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[s
if parsed["hardcore_clothing_continuity"] in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY
else defaults["hardcore_clothing_continuity"]
)
parsed["softcore_camera_mode"] = parsed["softcore_camera_mode"] if parsed["softcore_camera_mode"] in CAMERA_MODE_PROMPTS else defaults["softcore_camera_mode"]
parsed["softcore_camera_mode"] = (
parsed["softcore_camera_mode"]
if parsed["softcore_camera_mode"] in CAMERA_MODE_PROMPTS or parsed["softcore_camera_mode"] == "from_camera_config"
else defaults["softcore_camera_mode"]
)
if (
parsed["hardcore_camera_mode"] not in CAMERA_MODE_PROMPTS
and parsed["hardcore_camera_mode"] not in ("from_camera_config", "same_as_softcore")
@@ -4616,7 +4816,7 @@ def build_insta_of_pair(
soft_partner_styling["pose"],
soft_row["scene_text"],
soft_row["composition"],
f"{soft_camera_config['camera_mode'].replace('_', ' ')} camera" if soft_camera_directive else "",
_camera_caption_text(soft_camera_config) if soft_camera_directive else "",
]
soft_caption = ", ".join(str(part).strip() for part in soft_caption_parts if str(part).strip())
hard_caption_parts = [
@@ -4629,7 +4829,7 @@ def build_insta_of_pair(
hard_row["item"],
hard_scene,
hard_composition,
f"{hard_camera_config['camera_mode'].replace('_', ' ')} camera" if hard_camera_directive else "",
_camera_caption_text(hard_camera_config) if hard_camera_directive else "",
]
hard_caption = ", ".join(str(part).strip() for part in hard_caption_parts if str(part).strip())
metadata = {