diff --git a/__init__.py b/__init__.py index d07f938..57bef8f 100644 --- a/__init__.py +++ b/__init__.py @@ -393,7 +393,6 @@ def _install_input_tooltips(node_classes: dict[str, type]) -> None: try: from .loop_nodes import ( - ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS, accumulator_delete_entries, @@ -401,14 +400,15 @@ try: accumulator_move_entry, accumulator_save_entries, ) + from .node_camera import ( + NODE_CLASS_MAPPINGS as CAMERA_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as CAMERA_NODE_DISPLAY_NAME_MAPPINGS, + ) from .node_seed_resolution import ( NODE_CLASS_MAPPINGS as SEED_RESOLUTION_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS, ) from .prompt_builder import ( - build_camera_config_json, - build_camera_orbit_config_json, - build_qwen_camera_config_json, build_cast_config_json, build_category_config_json, build_character_slot_json, @@ -428,17 +428,8 @@ try: build_insta_of_pair, build_prompt, build_prompt_from_configs, - camera_angle_choices, camera_detail_choices, - 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, - camera_shot_choices, cast_preset_choices, category_preset_choices, category_choices, @@ -478,7 +469,6 @@ try: from .sdxl_formatter import format_sdxl_prompt, sdxl_quality_preset_choices, sdxl_style_preset_choices except ImportError: from loop_nodes import ( - ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS, accumulator_delete_entries, @@ -486,14 +476,15 @@ except ImportError: accumulator_move_entry, accumulator_save_entries, ) + from node_camera import ( + NODE_CLASS_MAPPINGS as CAMERA_NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS as CAMERA_NODE_DISPLAY_NAME_MAPPINGS, + ) from node_seed_resolution import ( NODE_CLASS_MAPPINGS as SEED_RESOLUTION_NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS, ) from prompt_builder import ( - build_camera_config_json, - build_camera_orbit_config_json, - build_qwen_camera_config_json, build_cast_config_json, build_category_config_json, build_character_slot_json, @@ -513,17 +504,8 @@ except ImportError: build_insta_of_pair, build_prompt, build_prompt_from_configs, - camera_angle_choices, camera_detail_choices, - 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, - camera_shot_choices, cast_preset_choices, category_preset_choices, category_choices, @@ -754,178 +736,6 @@ class SxCPPromptBuilder: ) -class SxCPCameraControl: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "camera_mode": (camera_mode_choices(), {"default": "handheld_selfie"}), - "shot_size": (camera_shot_choices(), {"default": "auto"}), - "angle": (camera_angle_choices(), {"default": "auto"}), - "lens": (camera_lens_choices(), {"default": "smartphone_wide"}), - "distance": (camera_distance_choices(), {"default": "arm_length"}), - "orientation": (camera_orientation_choices(), {"default": "vertical_story"}), - "phone_visibility": (camera_phone_choices(), {"default": "phone_visible"}), - "priority": (camera_priority_choices(), {"default": "locked"}), - "camera_detail": (camera_detail_choices(), {"default": "compact"}), - } - } - - RETURN_TYPES = (SXCP_CAMERA_CONFIG,) - RETURN_NAMES = ("camera_config",) - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build( - self, - camera_mode, - shot_size, - angle, - lens, - distance, - orientation, - phone_visibility, - priority, - camera_detail, - ): - return ( - build_camera_config_json( - camera_mode=camera_mode, - shot_size=shot_size, - angle=angle, - lens=lens, - distance=distance, - orientation=orientation, - phone_visibility=phone_visibility, - priority=priority, - camera_detail=camera_detail, - ), - ) - - -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 = (SXCP_CAMERA_CONFIG, "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 SxCPQwenCameraTranslator: - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "qwen_prompt": ("STRING", {"default": ""}), - "prefer_camera_info": ("BOOLEAN", {"default": True}), - "camera_mode": (camera_mode_choices(), {"default": "standard"}), - "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": False}), - "suppress_phone_visibility": ("BOOLEAN", {"default": True}), - }, - "optional": { - "camera_info": (ANY_TYPE,), - }, - } - - RETURN_TYPES = (SXCP_CAMERA_CONFIG, "STRING", "STRING") - RETURN_NAMES = ("camera_config", "camera_prompt", "camera_info_json") - FUNCTION = "build" - CATEGORY = "prompt_builder" - - def build( - self, - qwen_prompt, - prefer_camera_info, - camera_mode, - subject_focus, - lens, - orientation, - phone_visibility, - priority, - camera_detail, - include_degrees, - suppress_phone_visibility, - camera_info=None, - ): - config = build_qwen_camera_config_json( - qwen_prompt=qwen_prompt or "", - camera_info=camera_info, - prefer_camera_info=prefer_camera_info, - camera_mode=camera_mode, - subject_focus=subject_focus, - lens=lens, - orientation=orientation, - phone_visibility=phone_visibility, - priority=priority, - camera_detail=camera_detail, - include_degrees=include_degrees, - suppress_phone_visibility=suppress_phone_visibility, - ) - 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): @@ -2671,10 +2481,8 @@ NODE_CLASS_MAPPINGS = { "SxCPPromptBuilder": SxCPPromptBuilder, } NODE_CLASS_MAPPINGS.update(SEED_RESOLUTION_NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(CAMERA_NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update({ - "SxCPCameraControl": SxCPCameraControl, - "SxCPCameraOrbitControl": SxCPCameraOrbitControl, - "SxCPQwenCameraTranslator": SxCPQwenCameraTranslator, "SxCPCategoryPreset": SxCPCategoryPreset, "SxCPLocationPool": SxCPLocationPool, "SxCPCompositionPool": SxCPCompositionPool, @@ -2715,10 +2523,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPPromptBuilder": "SxCP Prompt Builder", } NODE_DISPLAY_NAME_MAPPINGS.update(SEED_RESOLUTION_NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(CAMERA_NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update({ - "SxCPCameraControl": "SxCP Camera Control", - "SxCPCameraOrbitControl": "SxCP Camera Orbit Control", - "SxCPQwenCameraTranslator": "SxCP Qwen Camera Translator", "SxCPCategoryPreset": "SxCP Category Preset", "SxCPLocationPool": "SxCP Location Pool", "SxCPCompositionPool": "SxCP Composition Pool", diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index 2ef932c..d327728 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -272,7 +272,8 @@ Improve later: ### Node / UI Path -Owner: `__init__.py`, `node_seed_resolution.py`, `loop_nodes.py`, `web/*.js`. +Owner: `__init__.py`, `node_seed_resolution.py`, `node_camera.py`, +`loop_nodes.py`, `web/*.js`. Keep here: @@ -281,11 +282,14 @@ Keep here: - button actions; - dynamic input slots. - seed and resolution utility node declarations in `node_seed_resolution.py`. +- camera utility node declarations in `node_camera.py`. Already isolated: - seed/global-seed/seed-locker and SDXL/Krea2 resolution utility nodes live in `node_seed_resolution.py`, with registration maps imported by `__init__.py`. +- camera/orbit/Qwen translator utility nodes live in `node_camera.py`, with + registration maps imported by `__init__.py`. Improve later: diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index ad8030b..d5b33b3 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -25,7 +25,8 @@ When a result is wrong, first identify which layer owns the bad text: - Raw builder prompt acceptable, SDXL tags wrong: edit `sdxl_formatter.py`. - Natural caption/training caption wrong: edit `caption_naturalizer.py`. - UI/preview/loop behavior wrong: edit `__init__.py`, node family modules such - as `node_seed_resolution.py`, `loop_nodes.py`, or `web/*.js`. + as `node_seed_resolution.py` or `node_camera.py`, `loop_nodes.py`, or + `web/*.js`. ## High-Level Routes @@ -692,6 +693,7 @@ These do not own prompt pool wording, but they affect execution and review: | Accumulator | `loop_nodes.py`, `web/accumulator_preview.js` | Stores generated values/images during workflow execution and previews/reorders/deletes them. | | Persistent text preview | `loop_nodes.py`, `web/preview_any_text.js` | Stores any value as text and keeps it after workflow reload. | | Seed and resolution utility nodes | `node_seed_resolution.py`, imported by `__init__.py` | Global/per-axis seed configs plus SDXL/Krea width/height helpers. | +| Camera utility nodes | `node_camera.py`, imported by `__init__.py` | Direct camera config, orbit-to-camera config, and Qwen MultiAngle camera translation. | ## Drift Audit Helper diff --git a/node_camera.py b/node_camera.py new file mode 100644 index 0000000..03de990 --- /dev/null +++ b/node_camera.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import json + +try: + from .loop_nodes import ANY_TYPE + from .prompt_builder import ( + build_camera_config_json, + build_camera_orbit_config_json, + build_qwen_camera_config_json, + camera_angle_choices, + camera_detail_choices, + 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, + camera_shot_choices, + ) +except ImportError: # Allows local smoke tests from the repository root. + from loop_nodes import ANY_TYPE + from prompt_builder import ( + build_camera_config_json, + build_camera_orbit_config_json, + build_qwen_camera_config_json, + camera_angle_choices, + camera_detail_choices, + 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, + camera_shot_choices, + ) + + +SXCP_CAMERA_CONFIG = "SXCP_CAMERA_CONFIG" + + +class SxCPCameraControl: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "camera_mode": (camera_mode_choices(), {"default": "handheld_selfie"}), + "shot_size": (camera_shot_choices(), {"default": "auto"}), + "angle": (camera_angle_choices(), {"default": "auto"}), + "lens": (camera_lens_choices(), {"default": "smartphone_wide"}), + "distance": (camera_distance_choices(), {"default": "arm_length"}), + "orientation": (camera_orientation_choices(), {"default": "vertical_story"}), + "phone_visibility": (camera_phone_choices(), {"default": "phone_visible"}), + "priority": (camera_priority_choices(), {"default": "locked"}), + "camera_detail": (camera_detail_choices(), {"default": "compact"}), + } + } + + RETURN_TYPES = (SXCP_CAMERA_CONFIG,) + RETURN_NAMES = ("camera_config",) + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + camera_mode, + shot_size, + angle, + lens, + distance, + orientation, + phone_visibility, + priority, + camera_detail, + ): + return ( + build_camera_config_json( + camera_mode=camera_mode, + shot_size=shot_size, + angle=angle, + lens=lens, + distance=distance, + orientation=orientation, + phone_visibility=phone_visibility, + priority=priority, + camera_detail=camera_detail, + ), + ) + + +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 = (SXCP_CAMERA_CONFIG, "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 SxCPQwenCameraTranslator: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "qwen_prompt": ("STRING", {"default": ""}), + "prefer_camera_info": ("BOOLEAN", {"default": True}), + "camera_mode": (camera_mode_choices(), {"default": "standard"}), + "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": False}), + "suppress_phone_visibility": ("BOOLEAN", {"default": True}), + }, + "optional": { + "camera_info": (ANY_TYPE,), + }, + } + + RETURN_TYPES = (SXCP_CAMERA_CONFIG, "STRING", "STRING") + RETURN_NAMES = ("camera_config", "camera_prompt", "camera_info_json") + FUNCTION = "build" + CATEGORY = "prompt_builder" + + def build( + self, + qwen_prompt, + prefer_camera_info, + camera_mode, + subject_focus, + lens, + orientation, + phone_visibility, + priority, + camera_detail, + include_degrees, + suppress_phone_visibility, + camera_info=None, + ): + config = build_qwen_camera_config_json( + qwen_prompt=qwen_prompt or "", + camera_info=camera_info, + prefer_camera_info=prefer_camera_info, + camera_mode=camera_mode, + subject_focus=subject_focus, + lens=lens, + orientation=orientation, + phone_visibility=phone_visibility, + priority=priority, + camera_detail=camera_detail, + include_degrees=include_degrees, + suppress_phone_visibility=suppress_phone_visibility, + ) + parsed = json.loads(config) + camera_prompt = parsed.get("custom_camera_prompt", "") + return config, camera_prompt, json.dumps(parsed, ensure_ascii=True, sort_keys=True) + + +NODE_CLASS_MAPPINGS = { + "SxCPCameraControl": SxCPCameraControl, + "SxCPCameraOrbitControl": SxCPCameraOrbitControl, + "SxCPQwenCameraTranslator": SxCPQwenCameraTranslator, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SxCPCameraControl": "SxCP Camera Control", + "SxCPCameraOrbitControl": "SxCP Camera Orbit Control", + "SxCPQwenCameraTranslator": "SxCP Qwen Camera Translator", +} diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index ddcbc75..75a7b83 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -1706,6 +1706,78 @@ def smoke_node_utility_registration() -> None: _expect(krea_config.get("width") == krea_width and krea_config.get("height") == krea_height, "Krea2 config_json dimensions mismatch") +def smoke_node_camera_registration() -> None: + required_nodes = [ + "SxCPCameraControl", + "SxCPCameraOrbitControl", + "SxCPQwenCameraTranslator", + ] + for node_name in required_nodes: + _expect(node_name in sxcp_nodes.NODE_CLASS_MAPPINGS, f"{node_name} missing from node registry") + _expect(node_name in sxcp_nodes.NODE_DISPLAY_NAME_MAPPINGS, f"{node_name} missing from display registry") + + camera_control = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPCameraControl"] + camera_inputs = camera_control.INPUT_TYPES().get("required") or {} + _expect("camera_mode" in camera_inputs, "Camera Control lost camera_mode input") + _expect("tooltip" in camera_inputs["camera_mode"][1], "Camera Control tooltip injection missing") + camera_config = camera_control().build( + "handheld_selfie", + "three_quarter_body", + "high_angle", + "smartphone_wide", + "arm_length", + "vertical_story", + "phone_visible", + "locked", + "compact", + )[0] + parsed_camera = json.loads(camera_config) + _expect(parsed_camera.get("camera_mode") == "handheld_selfie", "Camera Control lost camera_mode") + _expect(parsed_camera.get("phone_visibility") == "phone_visible", "Camera Control lost phone visibility") + + orbit_config, orbit_prompt, orbit_info = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPCameraOrbitControl"]().build( + True, + "standard", + 45, + 0, + 5.5, + "from_zoom", + "auto", + "auto", + "auto", + "auto", + "soft_hint", + "compact", + True, + ) + parsed_orbit = json.loads(orbit_config) + _expect(parsed_orbit.get("camera_source") == "orbit", "Orbit camera lost source metadata") + _expect("front-right quarter view" in orbit_prompt, "Orbit camera prompt lost direction") + _expect(json.loads(orbit_info).get("orbit_azimuth") == 45, "Orbit info JSON lost azimuth") + + qwen_node = sxcp_nodes.NODE_CLASS_MAPPINGS["SxCPQwenCameraTranslator"] + qwen_inputs = qwen_node.INPUT_TYPES() + _expect("camera_info" in (qwen_inputs.get("optional") or {}), "Qwen translator lost camera_info optional input") + qwen_config, qwen_prompt, qwen_info = qwen_node().build( + " front-right quarter view eye-level shot medium shot", + True, + "standard", + "auto", + "auto", + "auto", + "auto", + "soft_hint", + "compact", + False, + True, + ) + parsed_qwen = json.loads(qwen_config) + _expect(parsed_qwen.get("camera_source") == "qwen_multiangle_prompt", "Qwen translator lost source metadata") + _expect(parsed_qwen.get("phone_visibility") == "auto", "Qwen translator should suppress phone visibility by default") + _expect("front-right quarter view" in qwen_prompt, "Qwen camera prompt lost direction") + _expect(json.loads(qwen_info).get("qwen_prompt", "").startswith(""), "Qwen info JSON lost original prompt") + + SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("builtin_single_woman", smoke_builtin_single), ("camera_scene_single", smoke_camera_scene_single), @@ -1730,6 +1802,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [ ("expression_disabled", smoke_no_expression_fallback), ("formatter_metadata_fixtures", smoke_formatter_metadata_fixtures), ("node_utility_registration", smoke_node_utility_registration), + ("node_camera_registration", smoke_node_camera_registration), ]