burns.moves#
The named camera moves, and the one resolver that expands one into a path.
A BurnsPath is resolved geometry: rectangles measured
against one picture’s pixels. That makes it the wrong thing to store when the
picture can change. What an editor wants to keep is the authored intent —
“push in on this, gently” — and have the rectangles computed against whatever
image is in the slot at render time. Replace the still and the move re-frames;
keep the still and the move is exactly what it was.
So this module is the vocabulary (MOVES) plus the one function that
turns an intent into geometry (resolve_move()).
One code path, two front doors. resolve_move takes a name or an
explicit BurnsPath (or its to_dict() payload), so a caller that stores
both a named move and an optional hand-corrected path override makes one call
and the choice between them is made here — once — rather than once per consumer.
This is an.ir.camera.camera_keys’ arrangement: one table, not two tables
reconciled by a test.
``seed`` replaces the ordinal, and does one job. The motion in a sequence
used to be derived from each panel’s position — style by i % 2, zoom by
i % 4, push-or-pull by the parity of i. Reordering one panel therefore
changed the camera on every panel after it, and “keep this move, change this
picture” was unexpressible. resolve_move is a pure function of its
arguments and never of a position; a caller mints a seed once, stores it
beside the move, and the move survives every reorder.
The seed’s only job is choosing which concrete move "auto" becomes
(choose_move()). It deliberately does not perturb a named move’s zoom
or framing: a named move is a decision, and a decision a seed can still nudge
is not one. Variety across a sequence comes from "auto" and from the
pictures themselves — the content-aware framing differs per image already.
>>> import numpy as np
>>> img = np.zeros((600, 800, 3), dtype='uint8'); img[380:520, 120:300] = 210
>>> path = resolve_move("push_in", image=img, aspect=16 / 9, seed=41)
>>> path.evaluate(1.0).zoom >= path.evaluate(0.0).zoom
True
>>> resolve_move("hold", image=img, aspect=16 / 9).evaluate(0.0) == \
... resolve_move("hold", image=img, aspect=16 / 9).evaluate(1.0)
True
Module Attributes
The identity of the resolver's geometry, for a caller's cache key. |
|
The area (share of the picture) around which a saliency keep-region that reaches the borders stops being a subject and becomes a centre hint. |
|
Default end magnification. |
|
How much of the available travel a drift uses, as a fraction of the room the window leaves inside the image. |
|
A ceiling on that travel, as a fraction of the window rather than of the image — how far the camera crosses its own frame. |
|
The least travel room a drift will accept, in image units. |
|
Every name a stored |
|
How often |
Functions
|
Which concrete move |
|
How |
|
Resolve an authored camera intent against |
Exceptions
A move that cannot be resolved into a path. |
- burns.moves.AUTO_WEIGHTS: Mapping[str, int] = mappingproxy({'push_in': 3, 'pull_out': 3, 'drift_left': 1, 'drift_right': 1, 'drift_up': 1, 'drift_down': 1})#
How often
"auto"picks each move, as integer weights. Pushes and pulls dominate because a commentary film is mostly faces and documents, where a drift wanders off the thing being talked about; the drifts are there so a long sequence does not read as one move repeated. Read-only on purpose. The poolchoose_move()draws from is built once at import, so assigning into a plain dict here would change the documented weights and change nothing about the moves — a silent no-op in a name that is in__all__. Retuning these is a burns change that bumpsRESOLVER_IMPL_VERSION, not something a consumer does at runtime.
- burns.moves.DFLT_ZOOM: float = 1.18#
Default end magnification. Enough that a slow push reads as movement on a 1080p frame, little enough that a found still is not visibly softened by the upscale.
A drift may render slightly above it: reaching
DRIFT_SPANneedsDRIFT_MIN_ROOMof travel room, which at this zoom is marginally more than the window leaves, so the floor raises it to ~1.2. That is the floor working, not a jitter — it is a pure function of the arguments.
- burns.moves.DIFFUSE_KEEP_AREA: float = 0.5#
The area (share of the picture) around which a saliency keep-region that reaches the borders stops being a subject and becomes a centre hint. On a detailed photograph gradient saliency marks nearly everything, and treating that as a subject capped every requested zoom at ~1.0, so
push_inrendered its ~1.07 floor whatever was asked (burns#21, measured on 10 of 10 archive photos). Such a region is replaced by the largest keep- region that still honours the requested zoom, centred where saliency points. An explicitfocusis never treated this way: it is a decision. It is the centre of a ramp (DIFFUSE_KEEP_RAMP) and applies only to a box touchingDIFFUSE_MIN_EDGESborders; the default centred box (DFLT_KEEP_BOX) touches none, so a uniform picture keeps its old framing.
- burns.moves.DRIFT_MIN_ROOM: float = 0.16666666666666669#
The least travel room a drift will accept, in image units. When the requested
zoomleaves less, the zoom is raised until it does — the same waycontent_aware_path’smin_zoomfloor wins over its framing cap. A drift with no room is a hold, and silently returning one is the failure this floor exists to prevent.It is derived, not chosen, and the derivation is the point. Travel is
min(room * DRIFT_TRAVEL, size * DRIFT_SPAN)withsize == 1 - room, so the span can only ever bind whenroom >= DRIFT_SPAN / (DRIFT_TRAVEL + DRIFT_SPAN)— one sixth, at the values above. Set below that and the two constants can never both bind: the floor silently wins everywhere, every room-limited drift crossesDRIFT_TRAVEL * DRIFT_MIN_ROOM / (1 - DRIFT_MIN_ROOM)of its frame, andDRIFT_SPAN’s promise that the two axes read at the same speed holds only on pictures with room to spare. At0.08it measured 2.3x apart on a portrait still delivered at 16:9 — the exact asymmetry DRIFT_SPAN exists to remove.tests/test_moves.pypins the relationship, so changing either constant without the other fails.
- burns.moves.DRIFT_SPAN: float = 0.1#
A ceiling on that travel, as a fraction of the window rather than of the image — how far the camera crosses its own frame.
Without it a drift’s speed is whatever room the picture happened to leave, which is not the same on both axes: on a 4:3 still delivered at 16:9 there is twice as much vertical room as horizontal, so
drift_uptravelled ~29 % of the frame wheredrift_lefttravelled ~9 % — the same named gesture at three times the speed, decided by the aspect of the picture.an’s camera hit the same asymmetry from the other direction and answered it the same way, with one fraction of the frame per axis (an.ir.camera.PAN_FRACTION).A tenth is calibrated against what the room-limited horizontal case already produces at
DFLT_ZOOM, and againstken_burns_path(style="drift")’s ~7 %: legible at 1080p over a normal panel, and not a whip.
- burns.moves.DRIFT_TRAVEL: float = 0.5#
How much of the available travel a drift uses, as a fraction of the room the window leaves inside the image. Less than
1.0on purpose: a drift that runs wall to wall starts and ends against an edge, which reads as the frame hitting a stop rather than as a camera continuing past the cut.
- burns.moves.MOVES: tuple[str, ...] = ('push_in', 'pull_out', 'drift_left', 'drift_right', 'drift_up', 'drift_down', 'hold', 'auto')#
Every name a stored
movefield may hold — one vocabulary, because it is one field."hold"and"auto"sit beside the directional moves for that reason: splitting them into separate constants would make a consumer union two tuples to validate one field. They differ in kind, not in membership, andmove_kind()is where that difference is read.burns owns this vocabulary. A consumer that needs to validate its own stored field before a render (rather than discovering a typo five minutes in) will mirror it; that mirror must be pinned equal to this tuple by a test that fails when burns is absent rather than skipping, or the two drift silently. burns is deliberately strict about the spelling — no case folding and no whitespace stripping — so a mirror written as a plain membership test agrees with it exactly.
- exception burns.moves.MoveError[source]#
Bases:
ValueErrorA move that cannot be resolved into a path.
A plain
ValueErrorsubclass, so a consumer that already catchesValueErroraround its authoring layer keeps working, and so nothing downstream needs to importburns.movesto handle it.
- burns.moves.RESOLVER_IMPL_VERSION: int = 2#
The identity of the resolver’s geometry, for a caller’s cache key.
A stored panel is an intent, so the pixels it becomes depend on constants that live here —
DFLT_ZOOM,DRIFT_TRAVEL,DRIFT_SPAN,DRIFT_MIN_ROOM,AUTO_WEIGHTSand the orderchoose_move()draws from. Retune any of them and an unchanged panel renders differently.A consumer that caches a render keyed on the panel alone would therefore serve the old frames forever, or silently produce a cut that disagrees with its siblings. Put this in the key. It is
nw.Transform.impl_version’s contract — a lock, not a receipt: bump it whenever the geometry changes for an unchanged input, and leave it alone for a docstring or a new move name.Deliberately not the package version: a burns release that touches only the renderer must not invalidate anybody’s cut, and
importlib.metadata.version("burns")is unreliable here besides.
- burns.moves.choose_move(seed)[source]#
Which concrete move
"auto"resolves to forseed.Public because “auto” is a decision a person may want to see (a UI showing
auto → drift_left) and to pin (liking what auto chose and storing that name instead, so it survives a change to the weights).A pure function of
seedalone — not of the image, and not of any position in a sequence. That is what makes a panel’s move survive both a reorder and a swapped still.- Return type:
Examples
>>> choose_move(7) == choose_move(7) True >>> choose_move(7) in MOVES and choose_move(7) != 'auto' True >>> len({choose_move(s) for s in range(60)}) > 1 # not one move forever True
- burns.moves.move_kind(move)[source]#
How
moveis grouped:"zoom","drift","static","select".For a UI that wants a compass of directions separately from the rest, read off the same table
resolve_move()dispatches on.- Return type:
Examples
>>> move_kind("drift_left"), move_kind("hold"), move_kind("auto") ('drift', 'static', 'select') >>> sorted(m for m in MOVES if move_kind(m) == 'drift') ['drift_down', 'drift_left', 'drift_right', 'drift_up']
- burns.moves.resolve_move(move, *, image, aspect, zoom=1.18, focus=None, seed=0, easing='ease-in-out', on_aspect_mismatch='raise', content_box=None)[source]#
Resolve an authored camera intent against
imageinto a path.- Parameters:
move (
Union[str,BurnsPath,Mapping[str,Any]]) – a name fromMOVES, or an explicitBurnsPath/ itsto_dict()payload. The two are the two front doors on this one path: a stored panel that carries both a named move and a hand-corrected override passes the override here and the choice is made in one place.image (
Any) – the still the move is framed against — a path,PIL.Imageor ndarray. Required, and read every time: the framing depends on what is in the picture, so swapping the still must re-frame. Do not cache the returned path against the intent.aspect (
Optional[float]) – the deliveredwidth / height.Nonemeans “match the image”. Required rather than defaulted, because a cut has a delivery size and quietly framing for the image’s aspect instead is a cover-crop nobody asked for.zoom (
float) –the end magnification. A pure function of the arguments — never jittered by
seedor by a position — but **bounded at both ends**, so the number you pass is a request:capped so the padded keep-region stays framed. A
focusfilling the frame therefore yields a nearly static move; the fix is a tighterfocus, not a biggerzoom. A keep-region that saliency found coveringDIFFUSE_KEEP_AREAor more of the picture is only a centre hint and does not cap: on a detailed photograph it covers nearly everything, and capping on it made every zoom render the same ~1.07.floored.
push_in/pull_outinheritcontent_aware_path’smin_zoom + 0.02(about 1.07), so a barely-there 1.02 push renders as 1.07;holdhas no such floor and honours 1.02 exactly. A drift is floored to whatever leavesDRIFT_MIN_ROOMof travel.
focus (
Any) – an explicit keep-region overriding the saliency estimate. ARect, a normalized(x, y, w, h)tuple, or any object with.x/.y/.w/.h. When given,salient_box()is not called at all.seed (
int) – chooses which move"auto"becomes, and nothing else. Mint it once per panel and store it; never derive it from a position, which is the defect this parameter exists to remove.easing (
Union[str,Callable[[float],float],Sequence[float]]) – CSS timing function or callable. A callable is fine here but cannot be serialized — seeBurnsPath.to_dict(). Applies to a named move; an explicit path carries its own.content_box (
Any) – the part ofimagethat is picture, as a normalized(x, y, w, h)(same forms asfocus). Saliency runs inside it, so the blurred fill or bars a caller composited around a still do not read as subject. Ignored whenfocusis given.on_aspect_mismatch (
str) – what to do when an explicit path was authored for a differentoutput_aspectthan the one being rendered."raise"(default) refuses, because honouring the stored rectangles at another shape cover-crops a framing somebody chose by hand."refit"rebuilds each keyframe at the new aspect, keeping what the author actually chose — where the camera looks at each instant, and how far in it is — and changing only the window shape. A cut in a second aspect is a real workflow (a vertical edit of a landscape film), and a panel carries one path, not one per delivery, so “author a second path” is not a remedy a caller can take;"refit"is.
- Return type:
- Returns:
A
BurnsPath. Duration is not part of it; pass that to the renderer.- Raises:
MoveError – an unknown move name, a malformed
focus, a non-positivezoomoraspect, or — under the defaulton_aspect_mismatch="raise"— an explicit path whoseoutput_aspectcontradictsaspect.
Note
imageis opened for every named move. For an explicit path it is read only when refitting, since resolved geometry needs no picture.Examples
>>> import numpy as np >>> img = np.zeros((600, 800, 3), dtype='uint8'); img[300:460, 500:700] = 230
A named move is a decision, so the seed does not touch it:
>>> resolve_move("push_in", image=img, aspect=16 / 9, seed=1) == \ ... resolve_move("push_in", image=img, aspect=16 / 9, seed=999) True
"auto"is where the seed does its one job:>>> a = resolve_move("auto", image=img, aspect=16 / 9, seed=3) >>> a == resolve_move("auto", image=img, aspect=16 / 9, seed=3) True
A drift travels — and
drift_rightends further right:>>> d = resolve_move("drift_right", image=img, aspect=16 / 9) >>> d.evaluate(1.0).x > d.evaluate(0.0).x True
An explicit path is the other front door, returned as authored:
>>> from burns import BurnsPath, Rect >>> hand = BurnsPath.from_start_end( ... Rect(0, 0, 1, 1), Rect(0.1, 0.1, 0.6, 0.6), output_aspect=16 / 9 ... ) >>> resolve_move(hand.to_dict(), image=img, aspect=16 / 9) == hand True
Rendering it at another delivery refuses by default, and
"refit"keeps the authored look while changing the window shape:>>> resolve_move(hand, image=img, aspect=9 / 16) Traceback (most recent call last): ... burns.moves.MoveError: ... >>> v = resolve_move(hand, image=img, aspect=9 / 16, ... on_aspect_mismatch="refit") >>> v.output_aspect == 9 / 16 True >>> before = [round(c, 4) for c in hand.evaluate(1.0).center] >>> after = [round(c, 4) for c in v.evaluate(1.0).center] >>> before == after # the author's framing, at the new shape True