Source code for dazpy.cinematics

"""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