From 6a5e71719e00ece1103d0bd2c17094fa5de8c0d4 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 24 Jun 2026 18:42:36 +0200 Subject: [PATCH] Add camera orbit control --- README.md | 48 ++++++++--- __init__.py | 72 +++++++++++++++- krea_formatter.py | 10 +++ prompt_builder.py | 216 ++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 324 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 591e0bf..bb4328e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py index 63a76e4..2fd6889 100644 --- a/__init__.py +++ b/__init__.py @@ -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", diff --git a/krea_formatter.py b/krea_formatter.py index 5728581..864155b 100644 --- a/krea_formatter.py +++ b/krea_formatter.py @@ -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("_", " "), diff --git a/prompt_builder.py b/prompt_builder.py index 0dada48..1e73e30 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -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 = {