"""Domain-level camera shot builders built on the DazCamera/DazScene primitives.
Provides :func:`apply_static_shot` for a single camera placement/framing,
:func:`apply_orbit_camera` for a per-frame orbit sweep around a target,
:func:`apply_frame_subject` for distance-preset framing of a subject, and
:func:`apply_animated_shot` for a camera move driven by real DAZ Studio
keyframes (:meth:`~dazpy.DazNode.set_position_at_frame` /
:meth:`~dazpy.DazNode.set_rotation_at_frame`) rather than a per-frame
setValue bake — DAZ Studio interpolates between the given waypoints itself.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .math3 import Vec3
from ._shot_geometry import look_at_euler, resolve_target, spherical_offset
from ._timeline import DazTimeline
if TYPE_CHECKING:
from ._camera import DazCamera
from ._node import DazNode
from ._scene import DazScene
def _resolve_camera(scene: "DazScene", camera: "DazCamera | None", name: str | None) -> "DazCamera":
if camera is not None:
return camera
return scene.create_camera(name)
[docs]
@dataclass(frozen=True)
class CinematicStaticShot:
"""A single camera placement and optics configuration.
Args:
position: World-space camera position.
look_at: Aim target passed to :meth:`~dazpy.DazCamera.aim_at`. A
:class:`~dazpy.DazNode` is resolved via its
:attr:`~dazpy.DazNode.position`, raised by *look_at_offset_cm*.
Ignored if ``None``; in that case *rotation* (if set) is used
instead.
look_at_offset_cm: Vertical offset (cm) applied when resolving
*look_at* — see :func:`~dazpy._shot_geometry.resolve_target`.
Defaults to ``0.0`` since this API already takes an explicit
*position*/*look_at* the caller fully controls.
rotation: Explicit ``(x, y, z)`` degrees passed to
:meth:`~dazpy.DazNode.set_rotation`. Ignored if *look_at* is set.
focal_length: Passed to :attr:`~dazpy.DazCamera.focal_length`.
depth_of_field: Passed to :attr:`~dazpy.DazCamera.depth_of_field`.
focal_distance: Passed to :attr:`~dazpy.DazCamera.focal_distance`
when not ``None``; otherwise DAZ's current value is untouched.
aspect_width: Passed to :attr:`~dazpy.DazCamera.aspect_width` when
not ``None``.
aspect_height: Passed to :attr:`~dazpy.DazCamera.aspect_height` when
not ``None``.
pixels_width: Passed to :attr:`~dazpy.DazCamera.pixels_width` when
not ``None``.
pixels_height: Passed to :attr:`~dazpy.DazCamera.pixels_height` when
not ``None``.
"""
position: Vec3
look_at: "Vec3 | DazNode | None" = None
look_at_offset_cm: float = 0.0
rotation: tuple[float, float, float] | None = None
focal_length: float = 50.0
depth_of_field: bool = False
focal_distance: float | None = None
aspect_width: float | None = None
aspect_height: float | None = None
pixels_width: int | None = None
pixels_height: int | None = None
[docs]
def apply_static_shot(
scene: "DazScene",
shot: CinematicStaticShot,
*,
camera: "DazCamera | None" = None,
name: str | None = None,
) -> "DazCamera":
"""Place and configure a camera for *shot* in a single HTTP-round-trip set.
Args:
scene: A :class:`~dazpy.DazScene`. Only used to create a new camera
when *camera* is ``None``.
shot: The placement/optics configuration.
camera: An existing :class:`~dazpy.DazCamera` to reuse/mutate.
When ``None`` (the default), a new camera is created via
``scene.create_camera(name)``.
name: Optional name for a newly created camera. Ignored when
*camera* is given.
Returns:
The configured :class:`~dazpy.DazCamera` (either *camera* or the
newly created one).
"""
cam = _resolve_camera(scene, camera, name)
cam.set_position(shot.position.x, shot.position.y, shot.position.z)
if shot.look_at is not None:
target = resolve_target(shot.look_at, vertical_offset_cm=shot.look_at_offset_cm)
cam.aim_at(target.x, target.y, target.z)
elif shot.rotation is not None:
cam.set_rotation(*shot.rotation)
cam.focal_length = shot.focal_length
cam.depth_of_field = shot.depth_of_field
if shot.focal_distance is not None:
cam.focal_distance = shot.focal_distance
if shot.aspect_width is not None:
cam.aspect_width = shot.aspect_width
if shot.aspect_height is not None:
cam.aspect_height = shot.aspect_height
if shot.pixels_width is not None:
cam.pixels_width = shot.pixels_width
if shot.pixels_height is not None:
cam.pixels_height = shot.pixels_height
return cam
def _lerp(start: float, end: float, t: float) -> float:
return start + (end - start) * t
[docs]
@dataclass(frozen=True)
class OrbitCamera:
"""A camera sweeping around a target across a frame range.
Writes a static per-frame placement at each timeline frame — this is
**not** a real interpolated keyframe animation (see the module
docstring). Whether the sweep persists as visible motion when scrubbing
the timeline afterward depends on DAZ Studio's key/animation mode at
call time; that's the caller's responsibility.
Args:
target: The point to orbit around, as a
:class:`~dazpy.math3.Vec3` world position or a
:class:`~dazpy.DazNode` (its :attr:`~dazpy.DazNode.position`,
raised by *target_offset_cm*, is used).
radius: Orbit radius from the target, in DAZ Studio units (cm).
elevation_deg: Constant elevation angle throughout the orbit — see
:func:`~dazpy._shot_geometry.spherical_offset`.
start_azimuth_deg: Azimuth at *frame_start*.
end_azimuth_deg: Azimuth at *frame_end*. Azimuth is linearly
interpolated between the two across the frame range. Note the
class defaults sweep a full 0-360 degrees, which gives
*frame_start* and *frame_end* the same azimuth (a duplicate
endpoint) -- callers wanting a seamless loop should use e.g.
``end_azimuth_deg=356.0`` or ``frame_end=frame_start+89``
instead of a full 360.
frame_start: First timeline frame (inclusive).
frame_end: Last timeline frame (inclusive). Must be ``>=``
*frame_start*.
focal_length: Passed to :attr:`~dazpy.DazCamera.focal_length` once,
before the per-frame sweep.
target_offset_cm: Vertical offset (cm) applied when resolving
*target* — see :func:`~dazpy._shot_geometry.resolve_target`.
Defaults to ``25.0`` (chest height) since a figure's resolved
position is generally its root/hip joint and a close orbit
radius aimed straight at it risks clipping the head.
"""
target: "Vec3 | DazNode"
radius: float
elevation_deg: float = 15.0
start_azimuth_deg: float = 0.0
end_azimuth_deg: float = 360.0
frame_start: int = 0
frame_end: int = 90
focal_length: float = 50.0
target_offset_cm: float = 25.0
[docs]
def apply_orbit_camera(
scene: "DazScene",
orbit: OrbitCamera,
*,
camera: "DazCamera | None" = None,
name: str | None = None,
) -> "DazCamera":
"""Sweep a camera around *orbit.target* across its frame range.
Side effects: this widens/sets the scene's animation range to
``[orbit.frame_start, orbit.frame_end]`` via
:meth:`~dazpy.DazScene.set_anim_range` -- without this, DAZ Studio's
``Scene.setFrame()`` clamps to the scene's existing animation range
(typically 0-30 on a fresh scene), silently overwriting later frames
onto the clamped one. The scene's timeline frame is also left parked at
*orbit.frame_end* when this function returns -- it is not restored to
whatever frame was current beforehand.
Args:
scene: A :class:`~dazpy.DazScene`. Used to create a new camera when
*camera* is ``None``, and to widen the animation range to cover
the orbit's frame range.
orbit: The orbit configuration.
camera: An existing :class:`~dazpy.DazCamera` to reuse/mutate.
When ``None`` (the default), a new camera is created via
``scene.create_camera(name)``.
name: Optional name for a newly created camera. Ignored when
*camera* is given.
Returns:
The configured :class:`~dazpy.DazCamera`.
Raises:
ValueError: If ``orbit.frame_end`` is less than ``orbit.frame_start``.
"""
if orbit.frame_end < orbit.frame_start:
raise ValueError(
f"OrbitCamera.frame_end ({orbit.frame_end}) must be >= frame_start ({orbit.frame_start})"
)
cam = _resolve_camera(scene, camera, name)
scene.set_anim_range(orbit.frame_start, orbit.frame_end)
target = resolve_target(orbit.target, vertical_offset_cm=orbit.target_offset_cm)
cam.focal_length = orbit.focal_length
timeline = DazTimeline(cam._client)
frame_count = orbit.frame_end - orbit.frame_start
for frame in range(orbit.frame_start, orbit.frame_end + 1):
t = (frame - orbit.frame_start) / frame_count if frame_count > 0 else 0.0
azimuth = _lerp(orbit.start_azimuth_deg, orbit.end_azimuth_deg, t)
pos = spherical_offset(target, azimuth, orbit.elevation_deg, orbit.radius)
timeline.frame = frame
cam.set_position(pos.x, pos.y, pos.z)
cam.aim_at(target.x, target.y, target.z)
return cam
_SHOT_DISTANCES = {"close_up": 60.0, "medium": 150.0, "full_body": 300.0}
_SHOT_TARGET_OFFSETS_CM = {"close_up": 45.0, "medium": 25.0, "full_body": 0.0}
[docs]
@dataclass(frozen=True)
class FrameSubject:
"""A camera framing a subject at a named shot distance.
Args:
subject: The point to frame, as a :class:`~dazpy.math3.Vec3` world
position or a :class:`~dazpy.DazNode` (its
:attr:`~dazpy.DazNode.position`, raised by *target_offset_cm*,
is used).
shot_type: One of ``"close_up"``, ``"medium"``, ``"full_body"`` —
maps to a preset distance via a module-level table.
azimuth_deg: Camera azimuth around the subject — see
:func:`~dazpy._shot_geometry.spherical_offset`.
elevation_deg: Camera elevation around the subject.
focal_length: Passed to :attr:`~dazpy.DazCamera.focal_length`.
target_offset_cm: Vertical offset (cm) applied when resolving
*subject* — see :func:`~dazpy._shot_geometry.resolve_target`.
``None`` (the default) uses the *shot_type*'s entry in
``_SHOT_TARGET_OFFSETS_CM`` (tighter shots aim higher, to
compensate for a figure's resolved position being its
root/hip joint rather than chest/head height).
"""
subject: "Vec3 | DazNode"
shot_type: str = "medium"
azimuth_deg: float = 0.0
elevation_deg: float = 10.0
focal_length: float = 50.0
target_offset_cm: float | None = None
[docs]
def apply_frame_subject(
scene: "DazScene",
frame: FrameSubject,
*,
camera: "DazCamera | None" = None,
name: str | None = None,
) -> "DazCamera":
"""Place and aim a camera to frame *frame.subject* at its shot distance.
Args:
scene: A :class:`~dazpy.DazScene`. Only used to create a new camera
when *camera* is ``None``.
frame: The framing configuration.
camera: An existing :class:`~dazpy.DazCamera` to reuse/mutate.
When ``None`` (the default), a new camera is created via
``scene.create_camera(name)``.
name: Optional name for a newly created camera. Ignored when
*camera* is given.
Returns:
The configured :class:`~dazpy.DazCamera`.
Raises:
ValueError: If ``frame.shot_type`` is not one of ``"close_up"``,
``"medium"``, ``"full_body"``.
"""
if frame.shot_type not in _SHOT_DISTANCES:
raise ValueError(
f"Invalid FrameSubject.shot_type {frame.shot_type!r}; must be one of {sorted(_SHOT_DISTANCES)}"
)
cam = _resolve_camera(scene, camera, name)
offset = frame.target_offset_cm if frame.target_offset_cm is not None else _SHOT_TARGET_OFFSETS_CM[frame.shot_type]
target = resolve_target(frame.subject, vertical_offset_cm=offset)
pos = spherical_offset(target, frame.azimuth_deg, frame.elevation_deg, _SHOT_DISTANCES[frame.shot_type])
cam.set_position(pos.x, pos.y, pos.z)
cam.aim_at(target.x, target.y, target.z)
cam.focal_length = frame.focal_length
return cam
[docs]
@dataclass(frozen=True)
class CameraKeyframe:
"""A single waypoint in an animated camera move (see :class:`CinematicAnimatedShot`).
Args:
frame: Timeline frame number for this waypoint.
position: World-space camera position at *frame*.
look_at: Aim target at *frame*, resolved the same way as
:attr:`CinematicStaticShot.look_at`. Ignored if ``None``; in
that case *rotation* (if set) is used instead.
look_at_offset_cm: Vertical offset (cm) applied when resolving
*look_at* — see :func:`~dazpy._shot_geometry.resolve_target`.
rotation: Explicit ``(x, y, z)`` degrees for this waypoint. Ignored
if *look_at* is set.
"""
frame: int
position: Vec3
look_at: "Vec3 | DazNode | None" = None
look_at_offset_cm: float = 0.0
rotation: tuple[float, float, float] | None = None
[docs]
@dataclass(frozen=True)
class CinematicAnimatedShot:
"""A camera move driven by real DAZ Studio keyframes.
Unlike :class:`OrbitCamera` (which bakes a value on every timeline
frame), this writes one keyframe per :class:`CameraKeyframe` waypoint
and lets DAZ Studio interpolate the frames in between via its own
animation curves.
Args:
keyframes: The waypoints, in ascending, unique *frame* order (at
least two). Either every waypoint must specify an orientation
(*look_at* or *rotation*) or none must — a partial mix would
leave the rotation curve keyed at only some waypoints, holding
a stale orientation between them.
focal_length: Passed to :attr:`~dazpy.DazCamera.focal_length` once,
before the keyframes are written.
depth_of_field: Passed to :attr:`~dazpy.DazCamera.depth_of_field`
once.
focal_distance: Passed to :attr:`~dazpy.DazCamera.focal_distance`
once, when not ``None``; otherwise DAZ's current value is
untouched.
"""
keyframes: tuple[CameraKeyframe, ...]
focal_length: float = 50.0
depth_of_field: bool = False
focal_distance: float | None = None
[docs]
def apply_animated_shot(
scene: "DazScene",
shot: CinematicAnimatedShot,
*,
camera: "DazCamera | None" = None,
name: str | None = None,
) -> "DazCamera":
"""Write *shot.keyframes* as real DAZ Studio keyframes on a camera.
Side effects: clears any existing keys on the camera's position
controls (via :meth:`~dazpy.DazNode.clear_position_keys`) before
writing the new curve — a freshly created camera can already carry a
default key from creation time, which would otherwise distort
interpolation/extrapolation around the new keyframes. Rotation
controls are cleared the same way, but only if at least one keyframe
specifies an orientation.
Args:
scene: A :class:`~dazpy.DazScene`. Only used to create a new camera
when *camera* is ``None``.
shot: The keyframe sequence and optics configuration.
camera: An existing :class:`~dazpy.DazCamera` to reuse/mutate.
When ``None`` (the default), a new camera is created via
``scene.create_camera(name)``.
name: Optional name for a newly created camera. Ignored when
*camera* is given.
Returns:
The configured :class:`~dazpy.DazCamera`.
Raises:
ValueError: If fewer than two keyframes are given, if their
*frame* values are not strictly ascending, or if only some
keyframes specify an orientation (*look_at*/*rotation*).
"""
if len(shot.keyframes) < 2:
raise ValueError("CinematicAnimatedShot.keyframes needs at least two waypoints")
frames = [kf.frame for kf in shot.keyframes]
if frames != sorted(frames) or len(set(frames)) != len(frames):
raise ValueError("CinematicAnimatedShot.keyframes must have strictly ascending, unique frame numbers")
has_orientation = [kf.look_at is not None or kf.rotation is not None for kf in shot.keyframes]
if any(has_orientation) and not all(has_orientation):
raise ValueError(
"CinematicAnimatedShot.keyframes must either all specify an orientation "
"(look_at/rotation) or none of them should"
)
cam = _resolve_camera(scene, camera, name)
cam.focal_length = shot.focal_length
cam.depth_of_field = shot.depth_of_field
if shot.focal_distance is not None:
cam.focal_distance = shot.focal_distance
cam.clear_position_keys()
if any(has_orientation):
cam.clear_rotation_keys()
for kf in shot.keyframes:
cam.set_position_at_frame(kf.frame, kf.position.x, kf.position.y, kf.position.z)
if kf.look_at is not None:
target = resolve_target(kf.look_at, vertical_offset_cm=kf.look_at_offset_cm)
x, y, z = look_at_euler(kf.position, target)
cam.set_rotation_at_frame(kf.frame, x, y, z)
elif kf.rotation is not None:
cam.set_rotation_at_frame(kf.frame, *kf.rotation)
return cam