"""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
import random
from typing import Callable
from an.ir.schema import Camera, CameraKey, Shot
__all__ = [
"CAMERA_MOVES",
"PAN_FRACTION",
"CameraError",
"camera_keys",
"camera_shake_offsets",
]
#: 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."
)
if camera.follow is not None:
if camera.move not in (None, "", "hold") or camera.keys is not None:
raise CameraError(
f"shot {shot.id!r} sets camera.follow (on "
f"{camera.follow.target!r}) and also "
f"{'camera.keys' if camera.keys is not None else f'camera.move={camera.move!r}'}. "
"A follow IS the camera's path, so the two cannot both drive it. "
"Keep the follow, or the move."
)
# A follow's keys come from the COMPILED motion (an#445): the compiler
# resolves them (`camera_follow_keys`); at the IR level there are none.
return []
duration = max(0.001, float(shot.duration))
if camera.keys is not None:
keys = list(camera.keys)
_refuse_unknown_key_fields(shot, 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_unknown_key_fields(shot: Shot, keys: list[CameraKey]) -> None:
"""A key field the camera does not read is a typo, not a choice (an#454):
`{t: 0, x: 0}` would otherwise play `at: 0` and ignore `t`, silently."""
known = sorted(CameraKey.model_fields)
for i, key in enumerate(keys):
unknown = sorted(key.model_extra or {})
if unknown:
raise CameraError(
f"shot {shot.id!r}: camera key {i} has {unknown}, which no camera "
f"reads (a key is {{{', '.join(known)}}}: `at` in shot seconds, "
"`x`/`y` the camera's position in scene pixels, `+x` moving it "
"right, `zoom` a magnification, `rotation` in radians, `easing` "
"for the segment that starts at this key)."
)
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."
)
[docs]
def camera_shake_offsets(
shot: Shot, *, width: int, height: int
) -> list[tuple[float, float, float]]:
"""The shot's camera shakes as ``(time, dx, dy)`` screen offsets — the ONE resolver (an#429).
Empty when the shot has none. Each shake is at rest (``0, 0``) at its
start and its end; between, the frame jumps ``frequency`` times a second
to a seeded offset of at most ``amplitude`` × the frame height, scaled
down linearly to rest when ``decay``. Validate and the compiler both call
this, so a shake that validates cannot then raise (as with
:func:`camera_keys`).
>>> from an.ir.schema import Camera, CameraShake, Shot
>>> s = Shot(id="s", duration=2.0, camera=Camera(shake=[CameraShake(at=1.0, duration=0.25, frequency=8)]))
>>> [(t, round(dx, 2), round(dy, 2)) for t, dx, dy in camera_shake_offsets(s, width=1280, height=720)]
[(1.0, 0.0, 0.0), (1.125, 3.72, 2.79), (1.25, 0.0, 0.0)]
"""
camera: Camera | None = shot.camera
if camera is None or not camera.shake:
return []
duration = max(0.001, float(shot.duration))
shakes = sorted(camera.shake, key=lambda k: float(k.at))
out: list[tuple[float, float, float]] = []
for i, k in enumerate(shakes):
start, length = float(k.at), float(k.duration)
end = start + length
if start < 0.0 or end > duration + 1e-9:
raise CameraError(
f"shot {shot.id!r}: the camera shake at {start:g}s lasts {length:g}s, "
f"past the shot (0 … {duration:g}s): its frames would never play. "
"Shorten it or move it earlier."
)
if i and start < float(shakes[i - 1].at) + float(shakes[i - 1].duration) - 1e-9:
raise CameraError(
f"shot {shot.id!r}: two camera shakes overlap (at "
f"{float(shakes[i - 1].at):g}s and {start:g}s). Write one longer shake, "
"or move the second after the first ends."
)
steps = max(1, round(length * float(k.frequency)))
amp = float(k.amplitude) * float(height)
rng = random.Random(int(k.seed))
for j in range(steps + 1):
t = start + length * j / steps
if j in (0, steps):
out.append((t, 0.0, 0.0))
continue
env = 1.0 - j / steps if k.decay else 1.0
dx, dy = (amp * env * rng.uniform(-1.0, 1.0) for _ in range(2))
out.append((t, dx, dy))
return out