Add camera orbit control
This commit is contained in:
+208
-8
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user