From 54dc3f5f800fea685b66bd5fc8054d7a2f86730e Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 24 Jun 2026 19:15:30 +0200 Subject: [PATCH] Add Qwen camera translator --- README.md | 14 +++++ __init__.py | 66 ++++++++++++++++++++++- prompt_builder.py | 130 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b47487e..bd73165 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The node is registered as: - `prompt_builder / SxCP Seed Locker` - `prompt_builder / SxCP Camera Control` - `prompt_builder / SxCP Camera Orbit Control` +- `prompt_builder / SxCP Qwen Camera Translator` - `prompt_builder / SxCP For Loop Start` - `prompt_builder / SxCP For Loop End` - `prompt_builder / SxCP Loop Append` @@ -253,6 +254,19 @@ Orbit controls: contact points, or the environment. - `include_degrees`: keeps the numeric angle in the emitted camera phrase. +If you use `ComfyUI-qwenmultiangle`, keep its nicer Three.js camera viewer and +add `SxCP Qwen Camera Translator` after it: + +1. Connect Qwen Multiangle Camera `prompt` to translator `qwen_prompt`. +2. Optionally connect Qwen Multiangle Camera `camera_info` to translator + `camera_info`; this keeps exact numeric angle/zoom from the viewer. +3. Connect translator `camera_config` to `Prompt Builder`, `Prompt Builder From + Configs`, or `Insta/OF Prompt Pair`. + +The translator accepts the Qwen labels such as `front-right quarter view`, +`eye-level shot`, and `medium shot`, then emits the same `camera_config` format +as the native camera nodes. + `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 diff --git a/__init__.py b/__init__.py index 9add51a..f9d5bde 100644 --- a/__init__.py +++ b/__init__.py @@ -4,10 +4,11 @@ import json import random try: - from .loop_nodes import LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS + from .loop_nodes import ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_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, @@ -54,10 +55,11 @@ try: from .caption_naturalizer import naturalize_caption from .krea_formatter import format_krea2_prompt except ImportError: - from loop_nodes import LOOP_NODE_CLASS_MAPPINGS, LOOP_NODE_DISPLAY_NAME_MAPPINGS + from loop_nodes import ANY_TYPE, LOOP_NODE_CLASS_MAPPINGS, LOOP_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, @@ -443,6 +445,64 @@ class SxCPCameraOrbitControl: 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}), + }, + "optional": { + "camera_info": (ANY_TYPE,), + }, + } + + RETURN_TYPES = ("STRING", "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, + 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, + ) + 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): @@ -1304,6 +1364,7 @@ NODE_CLASS_MAPPINGS = { "SxCPSeedLocker": SxCPSeedLocker, "SxCPCameraControl": SxCPCameraControl, "SxCPCameraOrbitControl": SxCPCameraOrbitControl, + "SxCPQwenCameraTranslator": SxCPQwenCameraTranslator, "SxCPCategoryPreset": SxCPCategoryPreset, "SxCPCastControl": SxCPCastControl, "SxCPGenerationProfile": SxCPGenerationProfile, @@ -1327,6 +1388,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SxCPSeedLocker": "SxCP Seed Locker", "SxCPCameraControl": "SxCP Camera Control", "SxCPCameraOrbitControl": "SxCP Camera Orbit Control", + "SxCPQwenCameraTranslator": "SxCP Qwen Camera Translator", "SxCPCategoryPreset": "SxCP Category Preset", "SxCPCastControl": "SxCP Cast Control", "SxCPGenerationProfile": "SxCP Generation Profile", diff --git a/prompt_builder.py b/prompt_builder.py index 1e73e30..250c5f9 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math import random import re from pathlib import Path @@ -1666,6 +1667,135 @@ def build_camera_orbit_config_json( return json.dumps(config, ensure_ascii=True, sort_keys=True) +QWEN_CAMERA_DIRECTIONS = { + "front-right quarter view": 45, + "right side view": 90, + "back-right quarter view": 135, + "back view": 180, + "back-left quarter view": 225, + "left side view": 270, + "front-left quarter view": 315, + "front view": 0, +} +QWEN_CAMERA_ELEVATIONS = { + "low-angle shot": -30, + "eye-level shot": 0, + "elevated shot": 30, + "high-angle shot": 60, +} +QWEN_CAMERA_ZOOMS = { + "wide shot": 0.0, + "medium shot": 5.0, + "close-up": 8.0, +} +QWEN_CAMERA_SCENE_CENTER_Y = 0.5 + + +def _qwen_prompt_camera_values(qwen_prompt: Any) -> tuple[int, int, float]: + text = _clean_prompt_punctuation(str(qwen_prompt or "").lower().replace(",", " ")) + horizontal_angle = 0 + vertical_angle = 0 + zoom = 5.0 + for label, value in QWEN_CAMERA_DIRECTIONS.items(): + if label in text: + horizontal_angle = value + break + for label, value in QWEN_CAMERA_ELEVATIONS.items(): + if label in text: + vertical_angle = value + break + for label, value in QWEN_CAMERA_ZOOMS.items(): + if label in text: + zoom = value + break + return horizontal_angle, vertical_angle, zoom + + +def _camera_info_dict(camera_info: Any) -> dict[str, Any] | None: + if not camera_info: + return None + if isinstance(camera_info, dict): + return camera_info + if isinstance(camera_info, str): + try: + raw = json.loads(camera_info) + except json.JSONDecodeError: + return None + return raw if isinstance(raw, dict) else None + return None + + +def _qwen_camera_info_values(camera_info: Any) -> tuple[int, int, float] | None: + info = _camera_info_dict(camera_info) + if not info: + return None + position = info.get("position") if isinstance(info.get("position"), dict) else {} + target = info.get("target") if isinstance(info.get("target"), dict) else {} + try: + dx = float(position.get("x", 0.0)) - float(target.get("x", 0.0)) + dy = float(position.get("y", QWEN_CAMERA_SCENE_CENTER_Y)) - float( + target.get("y", QWEN_CAMERA_SCENE_CENTER_Y) + ) + dz = float(position.get("z", 0.0)) - float(target.get("z", 0.0)) + except (TypeError, ValueError): + return None + distance = math.sqrt(dx * dx + dy * dy + dz * dz) + if distance <= 0: + return None + horizontal_angle = int(round(math.degrees(math.atan2(dx, dz)))) % 360 + vertical_angle = int(round(math.degrees(math.asin(max(-1.0, min(1.0, dy / distance)))))) + zoom = max(0.0, min(10.0, ((2.6 - distance) / 2.0) * 10.0)) + return horizontal_angle, vertical_angle, round(zoom, 2) + + +def build_qwen_camera_config_json( + qwen_prompt: str = "", + camera_info: Any = None, + prefer_camera_info: bool = True, + camera_mode: str = "standard", + subject_focus: str = "auto", + lens: str = "auto", + orientation: str = "auto", + phone_visibility: str = "auto", + priority: str = "locked", + camera_detail: str = "compact", + include_degrees: bool = False, +) -> str: + info_values = _qwen_camera_info_values(camera_info) + if prefer_camera_info and info_values is not None: + horizontal_angle, vertical_angle, zoom = info_values + source = "qwen_multiangle_camera_info" + else: + horizontal_angle, vertical_angle, zoom = _qwen_prompt_camera_values(qwen_prompt) + source = "qwen_multiangle_prompt" + config = json.loads( + build_camera_orbit_config_json( + enabled=True, + camera_mode=camera_mode, + horizontal_angle=horizontal_angle, + vertical_angle=vertical_angle, + zoom=zoom, + framing="from_zoom", + subject_focus=subject_focus, + lens=lens, + orientation=orientation, + phone_visibility=phone_visibility, + priority=priority, + camera_detail=camera_detail, + include_degrees=include_degrees, + ) + ) + config["camera_source"] = source + config["qwen_prompt"] = str(qwen_prompt or "").strip() + if info_values is not None: + config["qwen_camera_info_values"] = { + "horizontal_angle": info_values[0], + "vertical_angle": info_values[1], + "zoom": info_values[2], + } + 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