From f552f76c1a1437119a85784040207dfdb1f83873 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 26 Jun 2026 23:53:34 +0200 Subject: [PATCH] Extract camera config policy --- camera_config.py | 633 +++++++++++++++++++ docs/prompt-architecture-improvement-plan.md | 12 +- docs/prompt-pool-routing-map.md | 8 +- node_camera.py | 4 +- prompt_builder.py | 559 +++------------- 5 files changed, 730 insertions(+), 486 deletions(-) create mode 100644 camera_config.py diff --git a/camera_config.py b/camera_config.py new file mode 100644 index 0000000..817bf74 --- /dev/null +++ b/camera_config.py @@ -0,0 +1,633 @@ +from __future__ import annotations + +import json +import math +import re +from typing import Any + + +CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] +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", +] + +CAMERA_MODE_PROMPTS = { + "disabled": "", + "standard": "", + "handheld_selfie": ( + "Camera mode: handheld smartphone selfie, close arm-length framing, visible creator-shot perspective, " + "slight wide-angle intimacy, direct eye contact, natural phone-camera composition." + ), + "mirror_selfie": ( + "Camera mode: mirror selfie with the phone visible in one hand, reflective framing, creator looking at the screen, " + "body and environment visible through the mirror." + ), + "phone_tripod": ( + "Camera mode: phone on tripod or ring-light stand, creator-facing social-video framing, stable vertical composition, " + "hands-free self-recorded setup." + ), + "creator_pov": ( + "Camera mode: creator-held POV, intimate subscriber-view angle, the creator controls the camera, close foreground body framing." + ), + "bed_selfie": ( + "Camera mode: bed selfie shot from a phone held above or beside the body, intimate close framing, sheets visible around the subject." + ), + "bathroom_mirror": ( + "Camera mode: bathroom mirror selfie, phone visible, tiled private room, close vertical framing, candid creator-shot energy." + ), + "phone_flash": ( + "Camera mode: direct phone-flash selfie, crisp flash highlights, candid night-post feeling, hard-edged smartphone shadows." + ), + "action_cam": ( + "Camera mode: body-mounted or handheld action-camera intimacy, very close wide-angle perspective, dynamic creator-shot framing." + ), +} + +CAMERA_COMPACT_LABELS = { + "disabled": "", + "standard": "", + "handheld_selfie": "handheld smartphone selfie", + "mirror_selfie": "mirror selfie", + "phone_tripod": "phone tripod / ring-light setup", + "creator_pov": "creator-held POV", + "bed_selfie": "bed selfie", + "bathroom_mirror": "bathroom mirror selfie", + "phone_flash": "phone-flash selfie", + "action_cam": "handheld action-camera view", + "full_body": "full body", + "three_quarter": "three-quarter body", + "waist_up": "waist-up", + "close_up": "close-up", + "extreme_close_up": "extreme close-up", + "eye_level": "eye-level", + "high_angle": "high-angle", + "low_angle": "low-angle", + "overhead": "overhead", + "side_profile": "side-profile", + "rear_view": "rear-view", + "mirror_reflection": "mirror reflection", + "smartphone_wide": "smartphone wide-angle", + "ultra_wide": "ultra-wide", + "portrait_lens": "phone portrait lens", + "telephoto": "telephoto-style", + "macro_detail": "macro detail", + "arm_length": "arm-length", + "near_body": "near-body", + "bedside": "bedside phone", + "room_corner": "room-corner phone", + "vertical_story": "vertical 9:16", + "square_feed": "square feed", + "horizontal": "horizontal", + "phone_visible": "phone visible", + "phone_hidden": "phone hidden", + "screen_reflection": "screen reflection", + "ring_light_visible": "ring light visible", +} + +CAMERA_SHOT_PROMPTS = { + "auto": "", + "full_body": "Shot size: full body visible, head-to-toe framing, no important body parts cropped out.", + "three_quarter": "Shot size: three-quarter body framing, face, torso, hips, and thighs clearly visible.", + "waist_up": "Shot size: waist-up creator framing with face and upper body as the focus.", + "close_up": "Shot size: close-up framing with face, expression, hands, and body contact emphasized.", + "extreme_close_up": "Shot size: extreme close-up detail shot, tightly framed and intimate.", +} + +CAMERA_ANGLE_PROMPTS = { + "auto": "", + "eye_level": "Angle: eye-level camera angle with direct creator eye contact.", + "high_angle": "Angle: high-angle selfie looking down toward the body.", + "low_angle": "Angle: low-angle phone camera looking upward from near the body.", + "overhead": "Angle: overhead phone shot looking down at the full pose.", + "side_profile": "Angle: side-profile camera view emphasizing body silhouette and contact points.", + "rear_view": "Angle: rear-view camera framing with the body turned away from the lens.", + "mirror_reflection": "Angle: mirror-reflection composition with the phone and reflected body placement readable.", +} + +CAMERA_LENS_PROMPTS = { + "auto": "", + "smartphone_wide": "Lens: smartphone wide-angle lens with slight edge distortion and close personal scale.", + "ultra_wide": "Lens: ultra-wide phone lens, exaggerated near-camera perspective, environmental context visible.", + "portrait_lens": "Lens: phone portrait mode, shallow depth of field, crisp subject separation.", + "telephoto": "Lens: compressed telephoto-style framing, flatter proportions, less distortion.", + "macro_detail": "Lens: macro-detail phone shot focused on texture, skin, fabric, and contact detail.", +} + +CAMERA_DISTANCE_PROMPTS = { + "auto": "", + "arm_length": "Camera distance: arm-length selfie distance, close enough to feel handheld.", + "near_body": "Camera distance: near-body camera placement with intimate foreground framing.", + "bedside": "Camera distance: phone placed beside the body on the bed or floor.", + "room_corner": "Camera distance: phone set across the room, self-recorded but wider and more observational.", +} + +CAMERA_ORIENTATION_PROMPTS = { + "auto": "", + "vertical_story": "Orientation: vertical 9:16 story/reel framing.", + "square_feed": "Orientation: square social-feed crop.", + "horizontal": "Orientation: horizontal phone-video crop.", +} + +CAMERA_PHONE_PROMPTS = { + "auto": "", + "phone_visible": "Phone visibility: phone visible in hand or mirror, clearly creator-shot.", + "phone_hidden": "Phone visibility: phone is implied but not visible, preserving the selfie/creator-shot perspective.", + "screen_reflection": "Phone visibility: screen glow or reflection visible in the scene.", + "ring_light_visible": "Phone visibility: ring light or tripod visible enough to read as self-recorded content.", +} + +CAMERA_PRIORITY_PROMPTS = { + "soft_hint": "Camera priority: treat the camera notes as style guidance.", + "strong": "Camera priority: strongly preserve the selected camera, lens, angle, crop, and phone-shot perspective.", + "locked": "Camera priority: locked camera constraint; do not replace this with a studio, third-person, cinematic, or unrelated camera view.", +} + +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 _is_false(value: Any) -> bool: + if isinstance(value, bool): + return value is False + if isinstance(value, str): + return value.strip().lower() in ("false", "0", "no", "off") + return False + + +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 _clean_prompt_punctuation(text: str) -> str: + text = re.sub(r"\s+", " ", str(text or "")).strip() + text = re.sub(r"\s+([,.;:])", r"\1", text) + text = re.sub(r"(?:,\s*){2,}", ", ", text) + text = re.sub(r"\.\s*\.", ".", text) + text = re.sub(r":\s*\.", ".", text) + return text.strip() + + +def camera_mode_choices() -> list[str]: + return list(CAMERA_MODE_PROMPTS) + + +def camera_detail_choices() -> list[str]: + return list(CAMERA_DETAIL_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) + + +def camera_angle_choices() -> list[str]: + return list(CAMERA_ANGLE_PROMPTS) + + +def camera_lens_choices() -> list[str]: + return list(CAMERA_LENS_PROMPTS) + + +def camera_distance_choices() -> list[str]: + return list(CAMERA_DISTANCE_PROMPTS) + + +def camera_orientation_choices() -> list[str]: + return list(CAMERA_ORIENTATION_PROMPTS) + + +def camera_phone_choices() -> list[str]: + return list(CAMERA_PHONE_PROMPTS) + + +def camera_priority_choices() -> list[str]: + return list(CAMERA_PRIORITY_PROMPTS) + + +def build_camera_config_json( + camera_mode: str = "standard", + shot_size: str = "auto", + angle: str = "auto", + lens: str = "auto", + distance: str = "auto", + orientation: str = "auto", + phone_visibility: str = "auto", + priority: str = "strong", + camera_detail: str = "compact", +) -> str: + return json.dumps( + { + "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, + }, + ensure_ascii=True, + sort_keys=True, + ) + + +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 _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, + suppress_phone_visibility: bool = True, +) -> 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="auto" if not _is_false(suppress_phone_visibility) else 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 parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, Any]: + defaults = { + "camera_mode": "standard", + "shot_size": "auto", + "angle": "auto", + "lens": "auto", + "distance": "auto", + "orientation": "auto", + "phone_visibility": "auto", + "priority": "strong", + "camera_detail": "compact", + } + if not camera_config: + return defaults + if isinstance(camera_config, dict): + raw = camera_config + else: + try: + raw = json.loads(str(camera_config)) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid camera_config JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError("camera_config must be a JSON object") + parsed = {**defaults, **raw} + 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"]), + "lens": _choice(parsed.get("lens"), CAMERA_LENS_PROMPTS, defaults["lens"]), + "distance": _choice(parsed.get("distance"), CAMERA_DISTANCE_PROMPTS, defaults["distance"]), + "orientation": _choice(parsed.get("orientation"), CAMERA_ORIENTATION_PROMPTS, defaults["orientation"]), + "phone_visibility": _choice(parsed.get("phone_visibility"), CAMERA_PHONE_PROMPTS, defaults["phone_visibility"]), + "priority": _choice(parsed.get("priority"), CAMERA_PRIORITY_PROMPTS, defaults["priority"]), + "camera_detail": str(parsed.get("camera_detail") or defaults["camera_detail"]) + 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, 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, 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"], + parsed["shot_size"], + parsed["angle"], + parsed["lens"], + parsed["distance"], + parsed["orientation"], + parsed["phone_visibility"], + ] + 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) + "." + if parsed["priority"] == "locked": + directive += " Keep this camera framing." + return directive, parsed + parts = [ + CAMERA_MODE_PROMPTS[parsed["camera_mode"]], + CAMERA_SHOT_PROMPTS[parsed["shot_size"]], + CAMERA_ANGLE_PROMPTS[parsed["angle"]], + CAMERA_LENS_PROMPTS[parsed["lens"]], + CAMERA_DISTANCE_PROMPTS[parsed["distance"]], + 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 + parts.append(CAMERA_PRIORITY_PROMPTS[parsed["priority"]]) + return " ".join(parts), parsed + + +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" diff --git a/docs/prompt-architecture-improvement-plan.md b/docs/prompt-architecture-improvement-plan.md index c9ba51c..441a756 100644 --- a/docs/prompt-architecture-improvement-plan.md +++ b/docs/prompt-architecture-improvement-plan.md @@ -128,9 +128,10 @@ Already isolated: - climax role graph wording lives in `hardcore_role_climax.py`, covering ejaculation aftermath placement for face/body/ass, lap, open-thigh, side-lying, and front/back group layouts. -- camera-scene prose and coworking composition adaptation live in - `scene_camera_adapters.py`; `prompt_builder.py` still owns camera config - parsing and row mutation. +- camera option schema, orbit/Qwen translation, config parsing, camera + directive text, and camera caption text live in `camera_config.py`; + camera-scene prose and coworking composition adaptation live in + `scene_camera_adapters.py`; `prompt_builder.py` still owns row mutation. - shared hardcore environment-anchor cleanup lives in `hardcore_text_cleanup.py` and normalizes malformed pool joins before metadata reaches formatter routes. @@ -305,8 +306,9 @@ Already isolated: registration maps imported by `__init__.py`. - 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`. +- camera/orbit/Qwen translator utility nodes live in `node_camera.py`, using + `camera_config.py` for option lists and JSON builders, with registration maps + imported by `__init__.py`. - hair, age/body/eyes/clothing pools, manual character details, character slots, and profile save/load nodes live in `node_character.py`, with registration maps imported by `__init__.py`. diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index 8084826..6eeb404 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -68,6 +68,7 @@ Core helper ownership: | Python module | What it owns | | --- | --- | | `category_library.py` | JSON category loading, subcategory normalization, named scene/expression/composition pool loading, cast compatibility filtering, exact subcategory lookup, and inheritance-based pool merging. | +| `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. | | `pair_options.py` | Insta/OF option schema/defaults, softcore category/outfit/pose pools, partner outfit pools, clothing-continuity labels, negatives, and hardcore cast count policy. | | `pair_rows.py` | Insta/OF soft/hard row creation, softcore expression override resolution, Woman A slot context application, soft outfit/pose overrides, and POV row fields. | | `pair_camera.py` | Insta/OF soft/hard camera route resolution, same-as-softcore camera mode, camera-detail override, camera-aware composition mutation, POV camera suppression, and synchronized row/root camera metadata. | @@ -565,9 +566,10 @@ Camera config nodes: Camera handling: -1. Camera nodes emit `camera_config`. +1. Camera nodes emit `camera_config` through `camera_config.py`. 2. `build_prompt` calls `_apply_camera_config`. -3. `_camera_directive` creates a plain camera sentence unless disabled/off. +3. `camera_config.camera_directive` creates a plain camera sentence unless + disabled/off. 4. `_camera_scene_directive_for_context` can add location-aware camera text. 5. POV rows suppress the normal camera directive and use first-person camera wording instead. @@ -698,7 +700,7 @@ These do not own prompt pool wording, but they affect execution and review: | Persistent text preview | `loop_nodes.py`, `web/preview_any_text.js` | Stores any value as text and keeps it after workflow reload. | | Builder node wrappers | `node_builder.py`, imported by `__init__.py` | Direct prompt builder and config-driven prompt builder ComfyUI declarations. | | 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. | +| Camera utility nodes | `node_camera.py`, imported by `__init__.py` | UI wrappers for direct camera config, orbit-to-camera config, and Qwen MultiAngle camera translation via `camera_config.py`. | | Character utility nodes | `node_character.py`, imported by `__init__.py` | Hair, age/body/eyes/clothing pools, manual details, character slots, and profile save/load nodes. | | Hardcore position utility nodes | `node_hardcore_position.py`, imported by `__init__.py` | Position-family pool and action/filter gates for hardcore routes. | | Formatter utility nodes | `node_formatter.py`, imported by `__init__.py` | Caption naturalizer, Krea2 formatter, and SDXL formatter node wrappers. | diff --git a/node_camera.py b/node_camera.py index 03de990..4dde6a6 100644 --- a/node_camera.py +++ b/node_camera.py @@ -4,7 +4,7 @@ import json try: from .loop_nodes import ANY_TYPE - from .prompt_builder import ( + from .camera_config import ( build_camera_config_json, build_camera_orbit_config_json, build_qwen_camera_config_json, @@ -22,7 +22,7 @@ try: ) except ImportError: # Allows local smoke tests from the repository root. from loop_nodes import ANY_TYPE - from prompt_builder import ( + from camera_config import ( build_camera_config_json, build_camera_orbit_config_json, build_qwen_camera_config_json, diff --git a/prompt_builder.py b/prompt_builder.py index 9963e44..5244b7b 100644 --- a/prompt_builder.py +++ b/prompt_builder.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import math import random import re from pathlib import Path @@ -24,6 +23,7 @@ try: read_category_json as _read_json, template_list as _template_list, ) + from . import camera_config as camera_policy from . import generate_prompt_batches as g from . import pair_clothing from . import pair_camera @@ -59,6 +59,7 @@ except ImportError: # Allows local smoke tests with `python -c`. read_category_json as _read_json, template_list as _template_list, ) + import camera_config as camera_policy import generate_prompt_batches as g import pair_clothing import pair_camera @@ -359,7 +360,7 @@ CHARACTER_EYE_COLOR_CHOICES = [ "gray_brown", ] -CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] +CAMERA_DETAIL_CHOICES = camera_policy.CAMERA_DETAIL_CHOICES HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"] HARDCORE_POSITION_FAMILY_CHOICES = [ "any", @@ -578,25 +579,8 @@ def _hardcore_position_keys(*parts: Any, axis_values: dict[str, Any] | None = No return keys -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", -] +CAMERA_ORBIT_FRAMING_CHOICES = camera_policy.CAMERA_ORBIT_FRAMING_CHOICES +CAMERA_ORBIT_FOCUS_CHOICES = camera_policy.CAMERA_ORBIT_FOCUS_CHOICES GENERIC_POSITIVE_SUFFIX = ( "Use crisp clean comic linework, detailed hatching, soft blended shading, " @@ -627,136 +611,15 @@ LAYOUT_TEMPLATE = ( "Avoid: {negative_prompt}. Use no readable text unless the layout naturally needs small decorative placeholder marks." ) -CAMERA_MODE_PROMPTS = { - "disabled": "", - "standard": "", - "handheld_selfie": ( - "Camera mode: handheld smartphone selfie, close arm-length framing, visible creator-shot perspective, " - "slight wide-angle intimacy, direct eye contact, natural phone-camera composition." - ), - "mirror_selfie": ( - "Camera mode: mirror selfie with the phone visible in one hand, reflective framing, creator looking at the screen, " - "body and environment visible through the mirror." - ), - "phone_tripod": ( - "Camera mode: phone on tripod or ring-light stand, creator-facing social-video framing, stable vertical composition, " - "hands-free self-recorded setup." - ), - "creator_pov": ( - "Camera mode: creator-held POV, intimate subscriber-view angle, the creator controls the camera, close foreground body framing." - ), - "bed_selfie": ( - "Camera mode: bed selfie shot from a phone held above or beside the body, intimate close framing, sheets visible around the subject." - ), - "bathroom_mirror": ( - "Camera mode: bathroom mirror selfie, phone visible, tiled private room, close vertical framing, candid creator-shot energy." - ), - "phone_flash": ( - "Camera mode: direct phone-flash selfie, crisp flash highlights, candid night-post feeling, hard-edged smartphone shadows." - ), - "action_cam": ( - "Camera mode: body-mounted or handheld action-camera intimacy, very close wide-angle perspective, dynamic creator-shot framing." - ), -} - -CAMERA_COMPACT_LABELS = { - "disabled": "", - "standard": "", - "handheld_selfie": "handheld smartphone selfie", - "mirror_selfie": "mirror selfie", - "phone_tripod": "phone tripod / ring-light setup", - "creator_pov": "creator-held POV", - "bed_selfie": "bed selfie", - "bathroom_mirror": "bathroom mirror selfie", - "phone_flash": "phone-flash selfie", - "action_cam": "handheld action-camera view", - "full_body": "full body", - "three_quarter": "three-quarter body", - "waist_up": "waist-up", - "close_up": "close-up", - "extreme_close_up": "extreme close-up", - "eye_level": "eye-level", - "high_angle": "high-angle", - "low_angle": "low-angle", - "overhead": "overhead", - "side_profile": "side-profile", - "rear_view": "rear-view", - "mirror_reflection": "mirror reflection", - "smartphone_wide": "smartphone wide-angle", - "ultra_wide": "ultra-wide", - "portrait_lens": "phone portrait lens", - "telephoto": "telephoto-style", - "macro_detail": "macro detail", - "arm_length": "arm-length", - "near_body": "near-body", - "bedside": "bedside phone", - "room_corner": "room-corner phone", - "vertical_story": "vertical 9:16", - "square_feed": "square feed", - "horizontal": "horizontal", - "phone_visible": "phone visible", - "phone_hidden": "phone hidden", - "screen_reflection": "screen reflection", - "ring_light_visible": "ring light visible", -} - -CAMERA_SHOT_PROMPTS = { - "auto": "", - "full_body": "Shot size: full body visible, head-to-toe framing, no important body parts cropped out.", - "three_quarter": "Shot size: three-quarter body framing, face, torso, hips, and thighs clearly visible.", - "waist_up": "Shot size: waist-up creator framing with face and upper body as the focus.", - "close_up": "Shot size: close-up framing with face, expression, hands, and body contact emphasized.", - "extreme_close_up": "Shot size: extreme close-up detail shot, tightly framed and intimate.", -} - -CAMERA_ANGLE_PROMPTS = { - "auto": "", - "eye_level": "Angle: eye-level camera angle with direct creator eye contact.", - "high_angle": "Angle: high-angle selfie looking down toward the body.", - "low_angle": "Angle: low-angle phone camera looking upward from near the body.", - "overhead": "Angle: overhead phone shot looking down at the full pose.", - "side_profile": "Angle: side-profile camera view emphasizing body silhouette and contact points.", - "rear_view": "Angle: rear-view camera framing with the body turned away from the lens.", - "mirror_reflection": "Angle: mirror-reflection composition with the phone and reflected body placement readable.", -} - -CAMERA_LENS_PROMPTS = { - "auto": "", - "smartphone_wide": "Lens: smartphone wide-angle lens with slight edge distortion and close personal scale.", - "ultra_wide": "Lens: ultra-wide phone lens, exaggerated near-camera perspective, environmental context visible.", - "portrait_lens": "Lens: phone portrait mode, shallow depth of field, crisp subject separation.", - "telephoto": "Lens: compressed telephoto-style framing, flatter proportions, less distortion.", - "macro_detail": "Lens: macro-detail phone shot focused on texture, skin, fabric, and contact detail.", -} - -CAMERA_DISTANCE_PROMPTS = { - "auto": "", - "arm_length": "Camera distance: arm-length selfie distance, close enough to feel handheld.", - "near_body": "Camera distance: near-body camera placement with intimate foreground framing.", - "bedside": "Camera distance: phone placed beside the body on the bed or floor.", - "room_corner": "Camera distance: phone set across the room, self-recorded but wider and more observational.", -} - -CAMERA_ORIENTATION_PROMPTS = { - "auto": "", - "vertical_story": "Orientation: vertical 9:16 story/reel framing.", - "square_feed": "Orientation: square social-feed crop.", - "horizontal": "Orientation: horizontal phone-video crop.", -} - -CAMERA_PHONE_PROMPTS = { - "auto": "", - "phone_visible": "Phone visibility: phone visible in hand or mirror, clearly creator-shot.", - "phone_hidden": "Phone visibility: phone is implied but not visible, preserving the selfie/creator-shot perspective.", - "screen_reflection": "Phone visibility: screen glow or reflection visible in the scene.", - "ring_light_visible": "Phone visibility: ring light or tripod visible enough to read as self-recorded content.", -} - -CAMERA_PRIORITY_PROMPTS = { - "soft_hint": "Camera priority: treat the camera notes as style guidance.", - "strong": "Camera priority: strongly preserve the selected camera, lens, angle, crop, and phone-shot perspective.", - "locked": "Camera priority: locked camera constraint; do not replace this with a studio, third-person, cinematic, or unrelated camera view.", -} +CAMERA_MODE_PROMPTS = camera_policy.CAMERA_MODE_PROMPTS +CAMERA_COMPACT_LABELS = camera_policy.CAMERA_COMPACT_LABELS +CAMERA_SHOT_PROMPTS = camera_policy.CAMERA_SHOT_PROMPTS +CAMERA_ANGLE_PROMPTS = camera_policy.CAMERA_ANGLE_PROMPTS +CAMERA_LENS_PROMPTS = camera_policy.CAMERA_LENS_PROMPTS +CAMERA_DISTANCE_PROMPTS = camera_policy.CAMERA_DISTANCE_PROMPTS +CAMERA_ORIENTATION_PROMPTS = camera_policy.CAMERA_ORIENTATION_PROMPTS +CAMERA_PHONE_PROMPTS = camera_policy.CAMERA_PHONE_PROMPTS +CAMERA_PRIORITY_PROMPTS = camera_policy.CAMERA_PRIORITY_PROMPTS _EXTENSIONS_APPLIED = False @@ -2820,7 +2683,7 @@ def _combined_negative(base: str, extra: str) -> str: def camera_mode_choices() -> list[str]: - return list(CAMERA_MODE_PROMPTS) + return camera_policy.camera_mode_choices() def ethnicity_choices() -> list[str]: @@ -2880,7 +2743,7 @@ def character_figure_choices() -> list[str]: def camera_detail_choices() -> list[str]: - return list(CAMERA_DETAIL_CHOICES) + return camera_policy.camera_detail_choices() def hardcore_detail_density_choices() -> list[str]: @@ -2925,39 +2788,39 @@ def character_hardcore_clothing_state_choices() -> list[str]: def camera_orbit_framing_choices() -> list[str]: - return list(CAMERA_ORBIT_FRAMING_CHOICES) + return camera_policy.camera_orbit_framing_choices() def camera_orbit_focus_choices() -> list[str]: - return list(CAMERA_ORBIT_FOCUS_CHOICES) + return camera_policy.camera_orbit_focus_choices() def camera_shot_choices() -> list[str]: - return list(CAMERA_SHOT_PROMPTS) + return camera_policy.camera_shot_choices() def camera_angle_choices() -> list[str]: - return list(CAMERA_ANGLE_PROMPTS) + return camera_policy.camera_angle_choices() def camera_lens_choices() -> list[str]: - return list(CAMERA_LENS_PROMPTS) + return camera_policy.camera_lens_choices() def camera_distance_choices() -> list[str]: - return list(CAMERA_DISTANCE_PROMPTS) + return camera_policy.camera_distance_choices() def camera_orientation_choices() -> list[str]: - return list(CAMERA_ORIENTATION_PROMPTS) + return camera_policy.camera_orientation_choices() def camera_phone_choices() -> list[str]: - return list(CAMERA_PHONE_PROMPTS) + return camera_policy.camera_phone_choices() def camera_priority_choices() -> list[str]: - return list(CAMERA_PRIORITY_PROMPTS) + return camera_policy.camera_priority_choices() def build_camera_config_json( @@ -2971,83 +2834,33 @@ def build_camera_config_json( priority: str = "strong", camera_detail: str = "compact", ) -> str: - return json.dumps( - { - "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, - }, - ensure_ascii=True, - sort_keys=True, + return camera_policy.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, ) 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" + return camera_policy._camera_orbit_direction(horizontal_angle) 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" + return camera_policy._camera_orbit_elevation(vertical_angle) 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" + return camera_policy._camera_orbit_distance(zoom, framing) 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"), "") + return camera_policy._camera_orbit_focus(subject_focus) def _camera_orbit_prompt( @@ -3058,27 +2871,14 @@ def _camera_orbit_prompt( 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", - } + return camera_policy.camera_orbit_prompt( + horizontal_angle, + vertical_angle, + zoom, + framing=framing, + subject_focus=subject_focus, + include_degrees=include_degrees, + ) def build_camera_orbit_config_json( @@ -3096,110 +2896,39 @@ def build_camera_orbit_config_json( camera_detail: str = "compact", include_degrees: bool = True, ) -> str: - orbit_prompt, orbit_metadata = _camera_orbit_prompt( - horizontal_angle, - vertical_angle, - zoom, + return camera_policy.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, ) - 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) -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 +QWEN_CAMERA_DIRECTIONS = camera_policy.QWEN_CAMERA_DIRECTIONS +QWEN_CAMERA_ELEVATIONS = camera_policy.QWEN_CAMERA_ELEVATIONS +QWEN_CAMERA_ZOOMS = camera_policy.QWEN_CAMERA_ZOOMS +QWEN_CAMERA_SCENE_CENTER_Y = camera_policy.QWEN_CAMERA_SCENE_CENTER_Y 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 + return camera_policy._qwen_prompt_camera_values(qwen_prompt) 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 + return camera_policy._camera_info_dict(camera_info) 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) + return camera_policy._qwen_camera_info_values(camera_info) def build_qwen_camera_config_json( @@ -3216,152 +2945,36 @@ def build_qwen_camera_config_json( include_degrees: bool = False, suppress_phone_visibility: bool = True, ) -> 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="auto" if not _is_false(suppress_phone_visibility) else phone_visibility, - priority=priority, - camera_detail=camera_detail, - include_degrees=include_degrees, - ) + return camera_policy.build_qwen_camera_config_json( + qwen_prompt=qwen_prompt, + 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, ) - 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 + return camera_policy._choice(value, choices, default) def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, Any]: - defaults = { - "camera_mode": "standard", - "shot_size": "auto", - "angle": "auto", - "lens": "auto", - "distance": "auto", - "orientation": "auto", - "phone_visibility": "auto", - "priority": "strong", - "camera_detail": "compact", - } - if not camera_config: - return defaults - if isinstance(camera_config, dict): - raw = camera_config - else: - try: - raw = json.loads(str(camera_config)) - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid camera_config JSON: {exc}") from exc - if not isinstance(raw, dict): - raise ValueError("camera_config must be a JSON object") - parsed = {**defaults, **raw} - 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"]), - "lens": _choice(parsed.get("lens"), CAMERA_LENS_PROMPTS, defaults["lens"]), - "distance": _choice(parsed.get("distance"), CAMERA_DISTANCE_PROMPTS, defaults["distance"]), - "orientation": _choice(parsed.get("orientation"), CAMERA_ORIENTATION_PROMPTS, defaults["orientation"]), - "phone_visibility": _choice(parsed.get("phone_visibility"), CAMERA_PHONE_PROMPTS, defaults["phone_visibility"]), - "priority": _choice(parsed.get("priority"), CAMERA_PRIORITY_PROMPTS, defaults["priority"]), - "camera_detail": str(parsed.get("camera_detail") or defaults["camera_detail"]) - 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 + return camera_policy.parse_camera_config(camera_config) 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 + return camera_policy.camera_config_with_mode(camera_config, camera_mode) 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"], - parsed["shot_size"], - parsed["angle"], - parsed["lens"], - parsed["distance"], - parsed["orientation"], - parsed["phone_visibility"], - ] - 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) + "." - if parsed["priority"] == "locked": - directive += " Keep this camera framing." - return directive, parsed - parts = [ - CAMERA_MODE_PROMPTS[parsed["camera_mode"]], - CAMERA_SHOT_PROMPTS[parsed["shot_size"]], - CAMERA_ANGLE_PROMPTS[parsed["angle"]], - CAMERA_LENS_PROMPTS[parsed["lens"]], - CAMERA_DISTANCE_PROMPTS[parsed["distance"]], - 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 - parts.append(CAMERA_PRIORITY_PROMPTS[parsed["priority"]]) - return " ".join(parts), parsed + return camera_policy.camera_directive(camera_config) def _insert_positive_directive(prompt: str, directive: str) -> str: @@ -3373,13 +2986,7 @@ def _insert_positive_directive(prompt: str, directive: str) -> str: 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" + return camera_policy.camera_caption_text(parsed) def _coworking_composition_prompt(scene_text: Any, composition: Any, subject_kind: str = "subjects") -> str: