Add camera orbit control

This commit is contained in:
2026-06-24 18:42:36 +02:00
parent b539d8c5f0
commit 6a5e71719e
4 changed files with 324 additions and 22 deletions
+208 -8
View File
@@ -180,6 +180,25 @@ CHARACTER_RANDOM_TOKENS = {"", "random", "auto", "global", "from_global", "defau
CAMERA_DETAIL_CHOICES = ["off", "compact", "full"]
HARDCORE_DETAIL_DENSITY_CHOICES = ["compact", "balanced", "dense"]
CAMERA_ORBIT_FRAMING_CHOICES = [
"from_zoom",
"wide",
"medium",
"full_body",
"three_quarter",
"close_up",
"extreme_close_up",
]
CAMERA_ORBIT_FOCUS_CHOICES = [
"auto",
"face",
"torso",
"hips",
"full_body",
"action",
"contact_points",
"environment",
]
GENERIC_POSITIVE_SUFFIX = (
"Use crisp clean comic linework, detailed hatching, soft blended shading, "
@@ -1450,6 +1469,14 @@ def hardcore_detail_density_choices() -> list[str]:
return list(HARDCORE_DETAIL_DENSITY_CHOICES)
def camera_orbit_framing_choices() -> list[str]:
return list(CAMERA_ORBIT_FRAMING_CHOICES)
def camera_orbit_focus_choices() -> list[str]:
return list(CAMERA_ORBIT_FOCUS_CHOICES)
def camera_shot_choices() -> list[str]:
return list(CAMERA_SHOT_PROMPTS)
@@ -1506,12 +1533,145 @@ def build_camera_config_json(
)
def _camera_orbit_direction(horizontal_angle: Any) -> str:
h_angle = int(float(horizontal_angle or 0)) % 360
if h_angle < 22.5 or h_angle >= 337.5:
return "front view"
if h_angle < 67.5:
return "front-right quarter view"
if h_angle < 112.5:
return "right side view"
if h_angle < 157.5:
return "back-right quarter view"
if h_angle < 202.5:
return "back view"
if h_angle < 247.5:
return "back-left quarter view"
if h_angle < 292.5:
return "left side view"
return "front-left quarter view"
def _camera_orbit_elevation(vertical_angle: Any) -> str:
vertical = int(float(vertical_angle or 0))
if vertical < -15:
return "low-angle shot"
if vertical < 15:
return "eye-level shot"
if vertical < 45:
return "elevated shot"
return "high-angle shot"
def _camera_orbit_distance(zoom: Any, framing: str = "from_zoom") -> str:
framing = framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom"
framing_labels = {
"wide": "wide shot",
"medium": "medium shot",
"full_body": "full-body shot",
"three_quarter": "three-quarter body shot",
"close_up": "close-up",
"extreme_close_up": "extreme close-up",
}
if framing != "from_zoom":
return framing_labels[framing]
zoom_value = float(zoom or 0.0)
if zoom_value < 2:
return "wide shot"
if zoom_value < 6:
return "medium shot"
return "close-up"
def _camera_orbit_focus(subject_focus: str) -> str:
return {
"face": "face and expression centered",
"torso": "torso and hands centered",
"hips": "hips and lower body centered",
"full_body": "full body centered",
"action": "main action centered",
"contact_points": "body contact points centered",
"environment": "subject and room both readable",
}.get(str(subject_focus or "auto"), "")
def _camera_orbit_prompt(
horizontal_angle: Any,
vertical_angle: Any,
zoom: Any,
framing: str = "from_zoom",
subject_focus: str = "auto",
include_degrees: bool = True,
) -> tuple[str, dict[str, Any]]:
azimuth = max(0, min(359, int(float(horizontal_angle or 0))))
elevation = max(-90, min(90, int(float(vertical_angle or 0))))
zoom_value = max(0.0, min(10.0, float(zoom or 0.0)))
direction = _camera_orbit_direction(azimuth)
elevation_label = _camera_orbit_elevation(elevation)
distance_label = _camera_orbit_distance(zoom_value, framing)
focus_label = _camera_orbit_focus(subject_focus)
pieces = [direction, elevation_label, distance_label, focus_label]
prompt = ", ".join(piece for piece in pieces if piece)
if include_degrees:
prompt = f"{azimuth}-degree {prompt}"
return prompt, {
"orbit_azimuth": azimuth,
"orbit_elevation": elevation,
"orbit_zoom": zoom_value,
"orbit_direction": direction,
"orbit_elevation_label": elevation_label,
"orbit_distance_label": distance_label,
"orbit_framing": framing if framing in CAMERA_ORBIT_FRAMING_CHOICES else "from_zoom",
"orbit_focus": subject_focus if subject_focus in CAMERA_ORBIT_FOCUS_CHOICES else "auto",
}
def build_camera_orbit_config_json(
enabled: bool = True,
camera_mode: str = "standard",
horizontal_angle: int = 0,
vertical_angle: int = 0,
zoom: float = 5.0,
framing: str = "from_zoom",
subject_focus: str = "auto",
lens: str = "auto",
orientation: str = "auto",
phone_visibility: str = "auto",
priority: str = "locked",
camera_detail: str = "compact",
include_degrees: bool = True,
) -> str:
orbit_prompt, orbit_metadata = _camera_orbit_prompt(
horizontal_angle,
vertical_angle,
zoom,
framing=framing,
subject_focus=subject_focus,
include_degrees=include_degrees,
)
config = {
"camera_mode": "disabled" if _is_false(enabled) else _choice(camera_mode, CAMERA_MODE_PROMPTS, "standard"),
"shot_size": "auto",
"angle": "auto",
"lens": _choice(lens, CAMERA_LENS_PROMPTS, "auto"),
"distance": "auto",
"orientation": _choice(orientation, CAMERA_ORIENTATION_PROMPTS, "auto"),
"phone_visibility": _choice(phone_visibility, CAMERA_PHONE_PROMPTS, "auto"),
"priority": _choice(priority, CAMERA_PRIORITY_PROMPTS, "locked"),
"camera_detail": camera_detail if camera_detail in CAMERA_DETAIL_CHOICES else "compact",
"camera_source": "orbit",
"custom_camera_prompt": orbit_prompt if not _is_false(enabled) else "",
**orbit_metadata,
}
return json.dumps(config, ensure_ascii=True, sort_keys=True)
def _choice(value: Any, choices: dict[str, str], default: str) -> str:
value = str(value or default)
return value if value in choices else default
def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, str]:
def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str, Any]:
defaults = {
"camera_mode": "standard",
"shot_size": "auto",
@@ -1535,7 +1695,9 @@ def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str
if not isinstance(raw, dict):
raise ValueError("camera_config must be a JSON object")
parsed = {**defaults, **raw}
return {
custom_camera_prompt = _clean_prompt_punctuation(parsed.get("custom_camera_prompt", "")).rstrip(".")
camera_source = str(parsed.get("camera_source") or "")
normalized = {
"camera_mode": _choice(parsed.get("camera_mode"), CAMERA_MODE_PROMPTS, defaults["camera_mode"]),
"shot_size": _choice(parsed.get("shot_size"), CAMERA_SHOT_PROMPTS, defaults["shot_size"]),
"angle": _choice(parsed.get("angle"), CAMERA_ANGLE_PROMPTS, defaults["angle"]),
@@ -1548,19 +1710,37 @@ def _parse_camera_config(camera_config: str | dict[str, Any] | None) -> dict[str
if str(parsed.get("camera_detail") or defaults["camera_detail"]) in CAMERA_DETAIL_CHOICES
else defaults["camera_detail"],
}
if custom_camera_prompt:
normalized["custom_camera_prompt"] = custom_camera_prompt
if camera_source:
normalized["camera_source"] = camera_source
for key in (
"orbit_azimuth",
"orbit_elevation",
"orbit_zoom",
"orbit_direction",
"orbit_elevation_label",
"orbit_distance_label",
"orbit_framing",
"orbit_focus",
):
if key in parsed:
normalized[key] = parsed[key]
return normalized
def _camera_config_with_mode(camera_config: str | dict[str, Any] | None, camera_mode: str) -> dict[str, str]:
def _camera_config_with_mode(camera_config: str | dict[str, Any] | None, camera_mode: str) -> dict[str, Any]:
parsed = _parse_camera_config(camera_config)
if camera_mode and camera_mode != "from_camera_config":
parsed["camera_mode"] = _choice(camera_mode, CAMERA_MODE_PROMPTS, parsed["camera_mode"])
return parsed
def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str, dict[str, str]]:
def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str, dict[str, Any]]:
parsed = _parse_camera_config(camera_config)
if parsed["camera_detail"] == "off" or parsed["camera_mode"] == "disabled":
return "", parsed
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
if parsed["camera_detail"] == "compact":
values = [
parsed["camera_mode"],
@@ -1573,6 +1753,8 @@ def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str,
]
labels = [CAMERA_COMPACT_LABELS.get(value, value.replace("_", " ")) for value in values]
labels = [label for value, label in zip(values, labels) if label and value != "auto"]
if custom_camera_prompt:
labels.append(custom_camera_prompt)
if not labels:
return "", parsed
directive = "Camera: " + ", ".join(labels) + "."
@@ -1588,6 +1770,8 @@ def _camera_directive(camera_config: str | dict[str, Any] | None) -> tuple[str,
CAMERA_ORIENTATION_PROMPTS[parsed["orientation"]],
CAMERA_PHONE_PROMPTS[parsed["phone_visibility"]],
]
if custom_camera_prompt:
parts.append(f"Camera orbit: {custom_camera_prompt}.")
parts = [part for part in parts if part]
if not parts:
return "", parsed
@@ -1603,6 +1787,16 @@ def _insert_positive_directive(prompt: str, directive: str) -> str:
return f"{prompt.rstrip()} {directive}"
def _camera_caption_text(parsed: dict[str, Any]) -> str:
custom_camera_prompt = str(parsed.get("custom_camera_prompt") or "").strip()
if custom_camera_prompt:
return custom_camera_prompt
camera_mode = str(parsed.get("camera_mode") or "").replace("_", " ").strip()
if not camera_mode or camera_mode == "standard":
return ""
return f"{camera_mode} camera framing"
def _apply_camera_config(row: dict[str, Any], camera_config: str | dict[str, Any] | None) -> dict[str, Any]:
directive, parsed = _camera_directive(camera_config)
row["camera_config"] = parsed
@@ -1610,7 +1804,9 @@ def _apply_camera_config(row: dict[str, Any], camera_config: str | dict[str, Any
if not directive:
return row
row["prompt"] = _insert_positive_directive(row["prompt"], directive)
row["caption"] = f"{row.get('caption', '').rstrip()}, {parsed['camera_mode'].replace('_', ' ')} camera framing"
camera_caption = _camera_caption_text(parsed)
if camera_caption:
row["caption"] = f"{row.get('caption', '').rstrip()}, {camera_caption}"
return row
@@ -4117,7 +4313,11 @@ def _parse_insta_of_options(options_json: str | dict[str, Any] | None) -> dict[s
if parsed["hardcore_clothing_continuity"] in INSTA_OF_HARDCORE_CLOTHING_CONTINUITY
else defaults["hardcore_clothing_continuity"]
)
parsed["softcore_camera_mode"] = parsed["softcore_camera_mode"] if parsed["softcore_camera_mode"] in CAMERA_MODE_PROMPTS else defaults["softcore_camera_mode"]
parsed["softcore_camera_mode"] = (
parsed["softcore_camera_mode"]
if parsed["softcore_camera_mode"] in CAMERA_MODE_PROMPTS or parsed["softcore_camera_mode"] == "from_camera_config"
else defaults["softcore_camera_mode"]
)
if (
parsed["hardcore_camera_mode"] not in CAMERA_MODE_PROMPTS
and parsed["hardcore_camera_mode"] not in ("from_camera_config", "same_as_softcore")
@@ -4616,7 +4816,7 @@ def build_insta_of_pair(
soft_partner_styling["pose"],
soft_row["scene_text"],
soft_row["composition"],
f"{soft_camera_config['camera_mode'].replace('_', ' ')} camera" if soft_camera_directive else "",
_camera_caption_text(soft_camera_config) if soft_camera_directive else "",
]
soft_caption = ", ".join(str(part).strip() for part in soft_caption_parts if str(part).strip())
hard_caption_parts = [
@@ -4629,7 +4829,7 @@ def build_insta_of_pair(
hard_row["item"],
hard_scene,
hard_composition,
f"{hard_camera_config['camera_mode'].replace('_', ' ')} camera" if hard_camera_directive else "",
_camera_caption_text(hard_camera_config) if hard_camera_directive else "",
]
hard_caption = ", ".join(str(part).strip() for part in hard_caption_parts if str(part).strip())
metadata = {