Extract camera config policy

This commit is contained in:
2026-06-26 23:53:34 +02:00
parent bc5ec35ef7
commit f552f76c1a
5 changed files with 730 additions and 486 deletions
+633
View File
@@ -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"
+7 -5
View File
@@ -128,9 +128,10 @@ Already isolated:
- climax role graph wording lives in `hardcore_role_climax.py`, covering - climax role graph wording lives in `hardcore_role_climax.py`, covering
ejaculation aftermath placement for face/body/ass, lap, open-thigh, ejaculation aftermath placement for face/body/ass, lap, open-thigh,
side-lying, and front/back group layouts. side-lying, and front/back group layouts.
- camera-scene prose and coworking composition adaptation live in - camera option schema, orbit/Qwen translation, config parsing, camera
`scene_camera_adapters.py`; `prompt_builder.py` still owns camera config directive text, and camera caption text live in `camera_config.py`;
parsing and row mutation. 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 - shared hardcore environment-anchor cleanup lives in
`hardcore_text_cleanup.py` and normalizes malformed pool joins before metadata `hardcore_text_cleanup.py` and normalizes malformed pool joins before metadata
reaches formatter routes. reaches formatter routes.
@@ -305,8 +306,9 @@ Already isolated:
registration maps imported by `__init__.py`. registration maps imported by `__init__.py`.
- seed/global-seed/seed-locker and SDXL/Krea2 resolution utility nodes live in - seed/global-seed/seed-locker and SDXL/Krea2 resolution utility nodes live in
`node_seed_resolution.py`, with registration maps imported by `__init__.py`. `node_seed_resolution.py`, with registration maps imported by `__init__.py`.
- camera/orbit/Qwen translator utility nodes live in `node_camera.py`, with - camera/orbit/Qwen translator utility nodes live in `node_camera.py`, using
registration maps imported by `__init__.py`. `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 - hair, age/body/eyes/clothing pools, manual character details, character
slots, and profile save/load nodes live in `node_character.py`, with slots, and profile save/load nodes live in `node_character.py`, with
registration maps imported by `__init__.py`. registration maps imported by `__init__.py`.
+5 -3
View File
@@ -68,6 +68,7 @@ Core helper ownership:
| Python module | What it owns | | 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. | | `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_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_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. | | `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: 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`. 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. 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 5. POV rows suppress the normal camera directive and use first-person camera
wording instead. 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | Formatter utility nodes | `node_formatter.py`, imported by `__init__.py` | Caption naturalizer, Krea2 formatter, and SDXL formatter node wrappers. |
+2 -2
View File
@@ -4,7 +4,7 @@ import json
try: try:
from .loop_nodes import ANY_TYPE from .loop_nodes import ANY_TYPE
from .prompt_builder import ( from .camera_config import (
build_camera_config_json, build_camera_config_json,
build_camera_orbit_config_json, build_camera_orbit_config_json,
build_qwen_camera_config_json, build_qwen_camera_config_json,
@@ -22,7 +22,7 @@ try:
) )
except ImportError: # Allows local smoke tests from the repository root. except ImportError: # Allows local smoke tests from the repository root.
from loop_nodes import ANY_TYPE from loop_nodes import ANY_TYPE
from prompt_builder import ( from camera_config import (
build_camera_config_json, build_camera_config_json,
build_camera_orbit_config_json, build_camera_orbit_config_json,
build_qwen_camera_config_json, build_qwen_camera_config_json,
+83 -476
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import math
import random import random
import re import re
from pathlib import Path from pathlib import Path
@@ -24,6 +23,7 @@ try:
read_category_json as _read_json, read_category_json as _read_json,
template_list as _template_list, template_list as _template_list,
) )
from . import camera_config as camera_policy
from . import generate_prompt_batches as g from . import generate_prompt_batches as g
from . import pair_clothing from . import pair_clothing
from . import pair_camera from . import pair_camera
@@ -59,6 +59,7 @@ except ImportError: # Allows local smoke tests with `python -c`.
read_category_json as _read_json, read_category_json as _read_json,
template_list as _template_list, template_list as _template_list,
) )
import camera_config as camera_policy
import generate_prompt_batches as g import generate_prompt_batches as g
import pair_clothing import pair_clothing
import pair_camera import pair_camera
@@ -359,7 +360,7 @@ CHARACTER_EYE_COLOR_CHOICES = [
"gray_brown", "gray_brown",
] ]
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"] CAMERA_DETAIL_CHOICES = camera_policy.CAMERA_DETAIL_CHOICES
HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"] HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"]
HARDCORE_POSITION_FAMILY_CHOICES = [ HARDCORE_POSITION_FAMILY_CHOICES = [
"any", "any",
@@ -578,25 +579,8 @@ def _hardcore_position_keys(*parts: Any, axis_values: dict[str, Any] | None = No
return keys return keys
CAMERA_ORBIT_FRAMING_CHOICES = [ CAMERA_ORBIT_FRAMING_CHOICES = camera_policy.CAMERA_ORBIT_FRAMING_CHOICES
"from_zoom", CAMERA_ORBIT_FOCUS_CHOICES = camera_policy.CAMERA_ORBIT_FOCUS_CHOICES
"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 = ( GENERIC_POSITIVE_SUFFIX = (
"Use crisp clean comic linework, detailed hatching, soft blended shading, " "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." "Avoid: {negative_prompt}. Use no readable text unless the layout naturally needs small decorative placeholder marks."
) )
CAMERA_MODE_PROMPTS = { CAMERA_MODE_PROMPTS = camera_policy.CAMERA_MODE_PROMPTS
"disabled": "", CAMERA_COMPACT_LABELS = camera_policy.CAMERA_COMPACT_LABELS
"standard": "", CAMERA_SHOT_PROMPTS = camera_policy.CAMERA_SHOT_PROMPTS
"handheld_selfie": ( CAMERA_ANGLE_PROMPTS = camera_policy.CAMERA_ANGLE_PROMPTS
"Camera mode: handheld smartphone selfie, close arm-length framing, visible creator-shot perspective, " CAMERA_LENS_PROMPTS = camera_policy.CAMERA_LENS_PROMPTS
"slight wide-angle intimacy, direct eye contact, natural phone-camera composition." CAMERA_DISTANCE_PROMPTS = camera_policy.CAMERA_DISTANCE_PROMPTS
), CAMERA_ORIENTATION_PROMPTS = camera_policy.CAMERA_ORIENTATION_PROMPTS
"mirror_selfie": ( CAMERA_PHONE_PROMPTS = camera_policy.CAMERA_PHONE_PROMPTS
"Camera mode: mirror selfie with the phone visible in one hand, reflective framing, creator looking at the screen, " CAMERA_PRIORITY_PROMPTS = camera_policy.CAMERA_PRIORITY_PROMPTS
"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.",
}
_EXTENSIONS_APPLIED = False _EXTENSIONS_APPLIED = False
@@ -2820,7 +2683,7 @@ def _combined_negative(base: str, extra: str) -> str:
def camera_mode_choices() -> list[str]: def camera_mode_choices() -> list[str]:
return list(CAMERA_MODE_PROMPTS) return camera_policy.camera_mode_choices()
def ethnicity_choices() -> list[str]: def ethnicity_choices() -> list[str]:
@@ -2880,7 +2743,7 @@ def character_figure_choices() -> list[str]:
def camera_detail_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]: 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]: 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]: 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]: def camera_shot_choices() -> list[str]:
return list(CAMERA_SHOT_PROMPTS) return camera_policy.camera_shot_choices()
def camera_angle_choices() -> list[str]: def camera_angle_choices() -> list[str]:
return list(CAMERA_ANGLE_PROMPTS) return camera_policy.camera_angle_choices()
def camera_lens_choices() -> list[str]: def camera_lens_choices() -> list[str]:
return list(CAMERA_LENS_PROMPTS) return camera_policy.camera_lens_choices()
def camera_distance_choices() -> list[str]: def camera_distance_choices() -> list[str]:
return list(CAMERA_DISTANCE_PROMPTS) return camera_policy.camera_distance_choices()
def camera_orientation_choices() -> list[str]: def camera_orientation_choices() -> list[str]:
return list(CAMERA_ORIENTATION_PROMPTS) return camera_policy.camera_orientation_choices()
def camera_phone_choices() -> list[str]: def camera_phone_choices() -> list[str]:
return list(CAMERA_PHONE_PROMPTS) return camera_policy.camera_phone_choices()
def camera_priority_choices() -> list[str]: def camera_priority_choices() -> list[str]:
return list(CAMERA_PRIORITY_PROMPTS) return camera_policy.camera_priority_choices()
def build_camera_config_json( def build_camera_config_json(
@@ -2971,83 +2834,33 @@ def build_camera_config_json(
priority: str = "strong", priority: str = "strong",
camera_detail: str = "compact", camera_detail: str = "compact",
) -> str: ) -> str:
return json.dumps( return camera_policy.build_camera_config_json(
{ camera_mode=camera_mode,
"camera_mode": camera_mode, shot_size=shot_size,
"shot_size": shot_size, angle=angle,
"angle": angle, lens=lens,
"lens": lens, distance=distance,
"distance": distance, orientation=orientation,
"orientation": orientation, phone_visibility=phone_visibility,
"phone_visibility": phone_visibility, priority=priority,
"priority": priority, camera_detail=camera_detail,
"camera_detail": camera_detail,
},
ensure_ascii=True,
sort_keys=True,
) )
def _camera_orbit_direction(horizontal_angle: Any) -> str: def _camera_orbit_direction(horizontal_angle: Any) -> str:
h_angle = int(float(horizontal_angle or 0)) % 360 return camera_policy._camera_orbit_direction(horizontal_angle)
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: def _camera_orbit_elevation(vertical_angle: Any) -> str:
vertical = int(float(vertical_angle or 0)) return camera_policy._camera_orbit_elevation(vertical_angle)
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: def _camera_orbit_distance(zoom: Any, framing: str = "from_zoom") -> str:
framing = framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom" return camera_policy._camera_orbit_distance(zoom, framing)
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: def _camera_orbit_focus(subject_focus: str) -> str:
return { return camera_policy._camera_orbit_focus(subject_focus)
"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( def _camera_orbit_prompt(
@@ -3058,27 +2871,14 @@ def _camera_orbit_prompt(
subject_focus: str = "auto", subject_focus: str = "auto",
include_degrees: bool = True, include_degrees: bool = True,
) -> tuple[str, dict[str, Any]]: ) -> tuple[str, dict[str, Any]]:
azimuth = max(0, min(359, int(float(horizontal_angle or 0)))) return camera_policy.camera_orbit_prompt(
elevation = max(-90, min(90, int(float(vertical_angle or 0)))) horizontal_angle,
zoom_value = max(0.0, min(10.0, float(zoom or 0.0))) vertical_angle,
direction = _camera_orbit_direction(azimuth) zoom,
elevation_label = _camera_orbit_elevation(elevation) framing=framing,
distance_label = _camera_orbit_distance(zoom_value, framing) subject_focus=subject_focus,
focus_label = _camera_orbit_focus(subject_focus) include_degrees=include_degrees,
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( def build_camera_orbit_config_json(
@@ -3096,110 +2896,39 @@ def build_camera_orbit_config_json(
camera_detail: str = "compact", camera_detail: str = "compact",
include_degrees: bool = True, include_degrees: bool = True,
) -> str: ) -> str:
orbit_prompt, orbit_metadata = _camera_orbit_prompt( return camera_policy.build_camera_orbit_config_json(
horizontal_angle, enabled=enabled,
vertical_angle, camera_mode=camera_mode,
zoom, horizontal_angle=horizontal_angle,
vertical_angle=vertical_angle,
zoom=zoom,
framing=framing, framing=framing,
subject_focus=subject_focus, subject_focus=subject_focus,
lens=lens,
orientation=orientation,
phone_visibility=phone_visibility,
priority=priority,
camera_detail=camera_detail,
include_degrees=include_degrees, 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 = { QWEN_CAMERA_DIRECTIONS = camera_policy.QWEN_CAMERA_DIRECTIONS
"front-right quarter view": 45, QWEN_CAMERA_ELEVATIONS = camera_policy.QWEN_CAMERA_ELEVATIONS
"right side view": 90, QWEN_CAMERA_ZOOMS = camera_policy.QWEN_CAMERA_ZOOMS
"back-right quarter view": 135, QWEN_CAMERA_SCENE_CENTER_Y = camera_policy.QWEN_CAMERA_SCENE_CENTER_Y
"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]: def _qwen_prompt_camera_values(qwen_prompt: Any) -> tuple[int, int, float]:
text = _clean_prompt_punctuation(str(qwen_prompt or "").lower().replace(",", " ")) return camera_policy._qwen_prompt_camera_values(qwen_prompt)
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: def _camera_info_dict(camera_info: Any) -> dict[str, Any] | None:
if not camera_info: return camera_policy._camera_info_dict(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: def _qwen_camera_info_values(camera_info: Any) -> tuple[int, int, float] | None:
info = _camera_info_dict(camera_info) return camera_policy._qwen_camera_info_values(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( def build_qwen_camera_config_json(
@@ -3216,152 +2945,36 @@ def build_qwen_camera_config_json(
include_degrees: bool = False, include_degrees: bool = False,
suppress_phone_visibility: bool = True, suppress_phone_visibility: bool = True,
) -> str: ) -> str:
info_values = _qwen_camera_info_values(camera_info) return camera_policy.build_qwen_camera_config_json(
if prefer_camera_info and info_values is not None: qwen_prompt=qwen_prompt,
horizontal_angle, vertical_angle, zoom = info_values camera_info=camera_info,
source = "qwen_multiangle_camera_info" prefer_camera_info=prefer_camera_info,
else: camera_mode=camera_mode,
horizontal_angle, vertical_angle, zoom = _qwen_prompt_camera_values(qwen_prompt) subject_focus=subject_focus,
source = "qwen_multiangle_prompt" lens=lens,
config = json.loads( orientation=orientation,
build_camera_orbit_config_json( phone_visibility=phone_visibility,
enabled=True, priority=priority,
camera_mode=camera_mode, camera_detail=camera_detail,
horizontal_angle=horizontal_angle, include_degrees=include_degrees,
vertical_angle=vertical_angle, suppress_phone_visibility=suppress_phone_visibility,
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 _choice(value: Any, choices: dict[str, str], default: str) -> str: def _choice(value: Any, choices: dict[str, str], default: str) -> str:
value = str(value or default) return camera_policy._choice(value, choices, default)
return value if value in choices else default
def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, Any]: def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, Any]:
defaults = { return camera_policy.parse_camera_config(camera_config)
"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]: def _camera_config_with_mode(camera_config: str | dict[str, Any] | None, camera_mode: str) -> dict[str, Any]:
parsed = _parse_camera_config(camera_config) return camera_policy.camera_config_with_mode(camera_config, camera_mode)
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]]: def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str, dict[str, Any]]:
parsed = _parse_camera_config(camera_config) return camera_policy.camera_directive(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 _insert_positive_directive(prompt: str, directive: str) -> str: 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: def _camera_caption_text(parsed: dict[str, Any]) -> str:
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip() return camera_policy.camera_caption_text(parsed)
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 _coworking_composition_prompt(scene_text: Any, composition: Any, subject_kind: str = "subjects") -> str: def _coworking_composition_prompt(scene_text: Any, composition: Any, subject_kind: str = "subjects") -> str: