Source code for an.ir.camera

"""Camera semantics: the named moves, and the one resolver that expands them.

This module exists because the alternative was two hand-maintained tables. The
compiler owned `CAMERA_MOVES` and `an.ir.validate` owned a frozenset of the
same names, reconciled by a test — which works, and is not what "a move that
validates cannot then raise" means. It means *one table*.

It lives under `an/ir/` rather than in the cutout adapter because the IR layer
must not import an adapter, and validate is in the IR layer. The camera is IR
semantics anyway: `Camera`/`CameraKey` are schema models, and a renderer that
cannot honour a move says so through `can_render`, not by keeping its own
vocabulary.

The geometry it encodes, verified numerically against the vendored PixiJS
composition: `root.pivot` IS a 2D camera, because PixiJS composes
``world = position + M·(local − pivot)`` and the runtime indexes the centre
container as ``root``. `+x` moves the CAMERA right, which moves content left.
"""

from __future__ import annotations

from typing import Callable

from an.ir.schema import Camera, CameraKey, Shot

__all__ = [
    "CAMERA_MOVES",
    "PAN_FRACTION",
    "CameraError",
    "camera_keys",
]

#: How far a pan travels, as a fraction of the canvas width (a tilt uses the
#: same fraction of the height). A third of the frame is a legible move at any
#: resolution and is the one number a named pan has to choose; an author who
#: wants a different distance writes `keys` and says so.
PAN_FRACTION: float = 1.0 / 3.0


[docs] class CameraError(ValueError): """A camera that cannot be resolved into keys. A plain `ValueError` subclass so the IR layer can raise it without knowing about any renderer; the cutout compiler re-raises it as a `CutoutCompileError` at its own boundary. """
def _zoom_keys(duration: float, start: float, end: float) -> list[CameraKey]: """Two keys that vary only `zoom` — the pre-an#109 document, as keys.""" return [ CameraKey(at=0.0, zoom=start, easing="ease_in_out"), CameraKey(at=duration, zoom=end), ] def _move_keys(duration: float, axis: str, sign: float) -> list[CameraKey]: """Two keys that vary only one translation axis. The distance is filled in by :func:`camera_keys`, which is the only place that knows the canvas.""" return [ CameraKey(at=0.0, easing="ease_in_out"), CameraKey(at=duration, **{axis: sign}), ] #: The named moves, as KEY LISTS. `move` is sugar over `keys` — one code path, #: two front doors. #: #: The five zoom moves must desugar to **exactly** the document they produced #: before an#109, which is more specific than "a scale tween": two animations #: named `__camera__<shot>_scale_x`/`_scale_y`, one channel each targeting #: `root`, keyframes `[(0.0, s0, "ease_in_out"), (duration, s1, null)]`, on two #: tracks rooted at `"__camera__"`, and **no pivot channels**. The emitter emits #: only the channels that actually vary, which is what makes that true rather #: than merely intended — and what keeps every camera scene's contract hash #: where it was. CAMERA_MOVES: dict[str, Callable[[float], list[CameraKey]]] = { "hold": lambda d: [], "push_in": lambda d: _zoom_keys(d, 1.0, 1.25), "pull_out": lambda d: _zoom_keys(d, 1.0, 0.8), "zoom_in": lambda d: _zoom_keys(d, 1.0, 1.5), "zoom_out": lambda d: _zoom_keys(d, 1.0, 0.7), # an#109. `+x` moves the CAMERA right, so `pan_left` ends at negative x. # In film a lateral translation on a fixed head is a *truck*, not a pan; on # an orthographic 2D stage the two are indistinguishable, and the epic's # done-when says `pan_left`, so the ambiguity lives here rather than in a # purist rename nobody would search for. "pan_left": lambda d: _move_keys(d, "x", -1.0), "pan_right": lambda d: _move_keys(d, "x", 1.0), "tilt_up": lambda d: _move_keys(d, "y", -1.0), "tilt_down": lambda d: _move_keys(d, "y", 1.0), } #: Moves a pan/tilt scales by the canvas HEIGHT rather than the width. A frame #: is wider than it is tall, so one span for both axes makes a tilt travel #: further than the picture it is tilting across. _VERTICAL_MOVES: frozenset[str] = frozenset({"tilt_up", "tilt_down"})
[docs] def camera_keys(shot: Shot, *, width: int, height: int) -> list[CameraKey]: """The shot's camera as an explicit key list — the ONE resolver. `move` and `keys` are two front doors on one path. **Validate calls this and so does the compiler**, which is what makes "a move that validates cannot then raise" true by construction rather than by two tables agreeing — the arrangement it replaced (an#109 review, H-1). >>> from an.ir.schema import Camera, Shot >>> push = Shot(id="s", renderer="cutout", duration=2.0, camera=Camera(move="push_in")) >>> [(k.at, k.zoom) for k in camera_keys(push, width=320, height=240)] [(0.0, 1.0), (2.0, 1.25)] >>> pan = Shot(id="s", renderer="cutout", duration=2.0, camera=Camera(move="pan_left")) >>> [(k.at, round(k.x, 3)) for k in camera_keys(pan, width=320, height=240)] [(0.0, 0.0), (2.0, -106.667)] """ camera: Camera | None = shot.camera if camera is None: return [] if camera.move is not None and camera.keys is not None: raise CameraError( f"shot {shot.id!r} sets BOTH camera.move={camera.move!r} and " f"camera.keys ({len(camera.keys)} keys). They are two front doors " "on one path, so a scene that sets both says two things about the " "same camera and there is no reading that is not a guess. Keep the " "one you meant." ) duration = max(0.001, float(shot.duration)) if camera.keys is not None: keys = list(camera.keys) _refuse_keys_that_cannot_play(shot, keys, duration) return keys if camera.move is None: return [] # Normalise BEFORE the emptiness test, or the guard grows an arbitrary seam: # `move=""` fell through the falsiness check and was ignored, while # `move=" "` reached the lookup and raised. Same input, two behaviours. move = camera.move.strip() if not move: return [] if move not in CAMERA_MOVES: raise CameraError( f"shot {shot.id!r} asks for camera.move={move!r}, which the cutout " f"renderer does not implement (it has: {sorted(CAMERA_MOVES)}). " "Write `camera.keys` if you need a move the presets do not name." ) keys = CAMERA_MOVES[move](duration) # `_move_keys` carries a unit SIGN on its axis; the distance needs the # canvas, which only the caller knows. span = float(height if move in _VERTICAL_MOVES else width) * PAN_FRACTION return [k.model_copy(update={"x": k.x * span, "y": k.y * span}) for k in keys]
def _refuse_keys_that_cannot_play( shot: Shot, keys: list[CameraKey], duration: float ) -> None: """Keys the compiler would emit as keyframes that never play. Raised rather than warned, and raised HERE rather than only reported by validate, because the alternative is a silent no-op: a keyframe past the shot's end is emitted and never reached, and an out-of-order key runs the camera backwards through a curve the author did not write. Both are `test_loud_discards.py`'s subject matter. Raising also makes validate's ERROR a prediction of a raise, which is `_check_camera`'s stated contract — before an#109's review, three of its four errors fired on scenes that compiled fine (M-2). """ times = [float(k.at) for k in keys] if times != sorted(times): raise CameraError( f"shot {shot.id!r}: camera keys are not in time order ({times}). " "They are emitted as keyframes in list order, so an out-of-order " "key runs the camera backwards through a curve nobody wrote." ) outside = [t for t in times if not (0.0 <= t <= duration)] if outside: raise CameraError( f"shot {shot.id!r}: camera keys at {outside} are outside the shot " f"(0 … {duration}). A keyframe past the end never plays; one before " "the start opens the shot mid-move." )