codimate 0.1.0__cp39-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codimate/__init__.py +87 -0
- codimate/_codimate.pyd +0 -0
- codimate/explain.py +269 -0
- codimate/layout.py +308 -0
- codimate/scene.py +439 -0
- codimate/trace.py +157 -0
- codimate-0.1.0.dist-info/METADATA +340 -0
- codimate-0.1.0.dist-info/RECORD +10 -0
- codimate-0.1.0.dist-info/WHEEL +4 -0
- codimate-0.1.0.dist-info/sboms/codimate-py.cyclonedx.json +4802 -0
codimate/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Codimate — turn a running algorithm into an explainer video.
|
|
2
|
+
|
|
3
|
+
You write four things, and never a keyframe:
|
|
4
|
+
|
|
5
|
+
algorithm your normal code, with emit() where something happens
|
|
6
|
+
view what one moment looks like
|
|
7
|
+
motion how things travel between moments
|
|
8
|
+
timing how long each moment lasts
|
|
9
|
+
|
|
10
|
+
Codimate pairs shapes between moments **by name** and turns the differences
|
|
11
|
+
into movement. Everything per-frame — diffing, interpolation, drawing,
|
|
12
|
+
encoding — happens in Rust (ADR 0008).
|
|
13
|
+
|
|
14
|
+
## What your algorithm did
|
|
15
|
+
|
|
16
|
+
- `trace` — mark a function so Codimate can watch it run
|
|
17
|
+
- `emit` — say that something worth showing just happened
|
|
18
|
+
- `items` — a list whose entries keep their identity when they move
|
|
19
|
+
- `Item`, `Event`, `Trace`, `Frame` — what your view is handed
|
|
20
|
+
|
|
21
|
+
## What a moment looks like
|
|
22
|
+
|
|
23
|
+
- `Scene` — one picture: `rect`, `circle`, `polygon`, `arrow`, `text`, `line`, `formula`
|
|
24
|
+
- `ngon`, `star` — corners for a polygon, so you do not compute them
|
|
25
|
+
- `Group` — several shapes that move together
|
|
26
|
+
- `Handle` — what a shape call returns: `.fill()`, `.round()`, `.turn()`,
|
|
27
|
+
`.grow()`, `.on()`, `.write()`, chained
|
|
28
|
+
|
|
29
|
+
## Where things sit
|
|
30
|
+
|
|
31
|
+
- `canvas`, `width`, `height` — the frame
|
|
32
|
+
- `row`, `column`, `Slot` — divide it up, without coordinates
|
|
33
|
+
- `measure`, `measure_math` — how big text or a formula will actually be
|
|
34
|
+
|
|
35
|
+
## How it moves, and for how long
|
|
36
|
+
|
|
37
|
+
- `Rule` — the path a shape travels
|
|
38
|
+
- `Timing` — how long each event lasts
|
|
39
|
+
- `ease` — the curve the Engine uses, if you need to draw it
|
|
40
|
+
|
|
41
|
+
## Running it
|
|
42
|
+
|
|
43
|
+
- `explain` — gather algorithm, view, motion and timing
|
|
44
|
+
- `Explanation.render` — write the video
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
from .explain import Explanation, Rule, Timing, ease, explain
|
|
50
|
+
from .layout import (Place, Slot, at, canvas, column, height, measure, measure_math,
|
|
51
|
+
ngon, row, star, width)
|
|
52
|
+
from .scene import Group, Handle, Scene
|
|
53
|
+
from .trace import Event, Frame, Item, Trace, emit, items, trace
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
# what happened
|
|
57
|
+
"emit",
|
|
58
|
+
"trace",
|
|
59
|
+
"items",
|
|
60
|
+
"Item",
|
|
61
|
+
"Event",
|
|
62
|
+
"Trace",
|
|
63
|
+
"Frame",
|
|
64
|
+
# what it looks like
|
|
65
|
+
"Scene",
|
|
66
|
+
"Group",
|
|
67
|
+
"Handle",
|
|
68
|
+
# where things sit
|
|
69
|
+
"canvas",
|
|
70
|
+
"measure",
|
|
71
|
+
"measure_math",
|
|
72
|
+
"width",
|
|
73
|
+
"height",
|
|
74
|
+
"Slot",
|
|
75
|
+
"Place",
|
|
76
|
+
"at",
|
|
77
|
+
"row",
|
|
78
|
+
"column",
|
|
79
|
+
"ngon",
|
|
80
|
+
"star",
|
|
81
|
+
# putting it together
|
|
82
|
+
"Rule",
|
|
83
|
+
"Timing",
|
|
84
|
+
"ease",
|
|
85
|
+
"explain",
|
|
86
|
+
"Explanation",
|
|
87
|
+
]
|
codimate/_codimate.pyd
ADDED
|
Binary file
|
codimate/explain.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Putting it together: motion rules, timing, and the render call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .layout import height, width
|
|
6
|
+
from .scene import Scene, _key
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .trace import Event, Frame, Trace
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
View = Callable[[Frame], Scene]
|
|
13
|
+
|
|
14
|
+
PATHS = ("straight", "linear", "fall", "lift_carry_drop")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Rule:
|
|
18
|
+
"""How things matching ``pattern`` travel. First matching rule wins.
|
|
19
|
+
|
|
20
|
+
cm.Rule("*", position="lift_carry_drop", clearance=90)
|
|
21
|
+
|
|
22
|
+
``pattern`` matches a shape's full name with ``*`` and ``?``. A shape
|
|
23
|
+
inside a group is named ``group/child``, so ``"3/*"`` targets one group and
|
|
24
|
+
``"*"`` targets everything.
|
|
25
|
+
|
|
26
|
+
Paths:
|
|
27
|
+
|
|
28
|
+
* ``straight`` — a straight line, easing in and out. The default, and what
|
|
29
|
+
you want when each event is a distinct step.
|
|
30
|
+
* ``linear`` — a straight line at constant speed. Use it when a thing is
|
|
31
|
+
mid-journey at every event, like something turning: easing would make it
|
|
32
|
+
accelerate and stop inside each segment.
|
|
33
|
+
* ``fall`` — a parabola: sideways at a constant rate, downwards
|
|
34
|
+
accelerating. What a dropped thing does, and what each hop of a falling
|
|
35
|
+
ball needs, since an eased path would settle gently instead of arriving.
|
|
36
|
+
* ``lift_carry_drop`` — arcs up and over, then falls. Takes ``clearance``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, pattern, position: str = "straight", **options: float) -> None:
|
|
40
|
+
if position not in PATHS:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"unknown path {position!r} — use one of {', '.join(PATHS)}")
|
|
43
|
+
self.pattern = _key(pattern) if isinstance(pattern, tuple) else str(pattern)
|
|
44
|
+
self.position = position
|
|
45
|
+
self.options = {k: float(v) for k, v in options.items()}
|
|
46
|
+
|
|
47
|
+
def _payload(self):
|
|
48
|
+
return (self.pattern, self.position, self.options)
|
|
49
|
+
|
|
50
|
+
def __repr__(self):
|
|
51
|
+
return f"Rule({self.pattern!r}, position={self.position!r}, **{self.options})"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Timing:
|
|
55
|
+
"""How long each event lasts, in seconds."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
default: float = 0.6,
|
|
61
|
+
events: "dict[str, float] | None" = None,
|
|
62
|
+
opening: float = 0.8,
|
|
63
|
+
final_hold: float = 1.2,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.default = default
|
|
66
|
+
self.events = events or {}
|
|
67
|
+
self.opening = opening
|
|
68
|
+
self.final_hold = final_hold
|
|
69
|
+
|
|
70
|
+
def for_event(self, event: Event) -> float:
|
|
71
|
+
"""How long ``event`` lasts: its own entry, or ``default``.
|
|
72
|
+
|
|
73
|
+
A name with no entry takes ``default`` silently — which is what a
|
|
74
|
+
default is for, but it means a misspelled event name costs you the
|
|
75
|
+
default duration rather than an error.
|
|
76
|
+
"""
|
|
77
|
+
return self.events.get(event.name, self.default)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ease(t: float) -> float:
|
|
81
|
+
"""The easing curve the Engine applies between two moments.
|
|
82
|
+
|
|
83
|
+
cm.ease(0.5) -> 0.5
|
|
84
|
+
|
|
85
|
+
This calls into the Engine, so it is the same curve your animation is
|
|
86
|
+
actually using — not a copy of it.
|
|
87
|
+
"""
|
|
88
|
+
from . import _codimate
|
|
89
|
+
|
|
90
|
+
return _codimate.ease(float(t))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Explanation:
|
|
94
|
+
"""A trace, a view and a timing, ready to render.
|
|
95
|
+
|
|
96
|
+
Built by :func:`explain` rather than directly. Holds one Scene per Trace
|
|
97
|
+
Event and the gap between each pair; :meth:`render` hands all of it to the
|
|
98
|
+
Engine once, and everything per-frame happens in there.
|
|
99
|
+
"""
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
trace: Trace,
|
|
104
|
+
view: View,
|
|
105
|
+
motion: "list[Rule] | None" = None,
|
|
106
|
+
timing: "Timing | None" = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.timing = timing or Timing()
|
|
109
|
+
self.motion = motion or []
|
|
110
|
+
self.trace = trace
|
|
111
|
+
|
|
112
|
+
# The view runs once per event, not per frame — this is the whole
|
|
113
|
+
# reason Python is fast enough to be the authoring language.
|
|
114
|
+
self.scenes = [view(Frame(state=trace.initial, event=None))]
|
|
115
|
+
for event in trace.events:
|
|
116
|
+
self.scenes.append(view(Frame(state=event.state, event=event)))
|
|
117
|
+
|
|
118
|
+
self.durations = [self.timing.for_event(e) for e in trace.events]
|
|
119
|
+
|
|
120
|
+
# A held opening and a held ending, expressed as segments that go
|
|
121
|
+
# nowhere. No special case in the Engine.
|
|
122
|
+
self.scenes.insert(0, self.scenes[0])
|
|
123
|
+
self.durations.insert(0, self.timing.opening)
|
|
124
|
+
self.scenes.append(self.scenes[-1])
|
|
125
|
+
self.durations.append(self.timing.final_hold)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def duration(self) -> float:
|
|
129
|
+
return sum(self.durations)
|
|
130
|
+
|
|
131
|
+
def render(self, output: str, *, fps: float = 30, scale: float = 1.0) -> str:
|
|
132
|
+
"""Draw every frame and write the video.
|
|
133
|
+
|
|
134
|
+
Coordinates always mean what `cm.canvas()` says — `scale` only changes
|
|
135
|
+
how many pixels each one becomes, so nothing in your view has to move:
|
|
136
|
+
|
|
137
|
+
.render("out.mp4", fps=60, scale=1.5) # 1080p60 from the default
|
|
138
|
+
|
|
139
|
+
Frames are rasterized at the larger size rather than upscaled
|
|
140
|
+
afterwards, so 1080p is genuinely drawn at 1080p.
|
|
141
|
+
|
|
142
|
+
The folder is created if it does not exist, so `render("results/x.mp4")`
|
|
143
|
+
works on a fresh clone.
|
|
144
|
+
"""
|
|
145
|
+
from pathlib import Path
|
|
146
|
+
|
|
147
|
+
from . import _codimate # imported here so the pure Python is testable
|
|
148
|
+
|
|
149
|
+
_find_encoder()
|
|
150
|
+
Path(output).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
_codimate.render(
|
|
153
|
+
scenes=[s._payload() for s in self.scenes],
|
|
154
|
+
cameras=[s._camera() for s in self.scenes],
|
|
155
|
+
rules=[r._payload() for r in self.motion],
|
|
156
|
+
durations=self.durations,
|
|
157
|
+
output=output,
|
|
158
|
+
width=width(),
|
|
159
|
+
height=height(),
|
|
160
|
+
fps=float(fps),
|
|
161
|
+
scale=float(scale),
|
|
162
|
+
)
|
|
163
|
+
return output
|
|
164
|
+
|
|
165
|
+
def frame_at(self, seconds: float, output: str = "frame.png",
|
|
166
|
+
scale: float = 1.0) -> str:
|
|
167
|
+
"""Save a single moment as a PNG, without rendering the video.
|
|
168
|
+
|
|
169
|
+
cm.explain(...).frame_at(12.5, "check.png")
|
|
170
|
+
|
|
171
|
+
The same scenes, timing and arithmetic as :meth:`render`, resolved at
|
|
172
|
+
one instant. Checking a frame by rendering the whole video and seeking
|
|
173
|
+
into it costs a minute to look at one second.
|
|
174
|
+
|
|
175
|
+
``scale`` matches ``render``'s, so the debug frame is rasterized the
|
|
176
|
+
way the video is — worth passing when you are checking text, which is
|
|
177
|
+
the thing that has historically differed between the two.
|
|
178
|
+
"""
|
|
179
|
+
from pathlib import Path
|
|
180
|
+
|
|
181
|
+
from . import _codimate
|
|
182
|
+
|
|
183
|
+
Path(output).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
_codimate.render_frame_png(
|
|
185
|
+
scenes=[s._payload() for s in self.scenes],
|
|
186
|
+
cameras=[s._camera() for s in self.scenes],
|
|
187
|
+
rules=[r._payload() for r in self.motion],
|
|
188
|
+
durations=self.durations,
|
|
189
|
+
seconds=float(seconds),
|
|
190
|
+
output=output,
|
|
191
|
+
width=width(),
|
|
192
|
+
height=height(),
|
|
193
|
+
scale=float(scale),
|
|
194
|
+
)
|
|
195
|
+
return output
|
|
196
|
+
|
|
197
|
+
def timeline(self) -> "list[tuple[float, float, str]]":
|
|
198
|
+
"""Every beat as ``(start, duration, event name)``, in seconds.
|
|
199
|
+
|
|
200
|
+
for start, length, name in cm.explain(...).timeline():
|
|
201
|
+
print(f"{start:6.2f} {length:4.2f} {name}")
|
|
202
|
+
|
|
203
|
+
What is on screen at 0:42, and how long each beat actually lasts —
|
|
204
|
+
the two questions you have when a video feels wrong. Pair it with
|
|
205
|
+
:meth:`frame_at` to look at the moment you find.
|
|
206
|
+
"""
|
|
207
|
+
# Read from `durations`, which already carries the held opening and
|
|
208
|
+
# ending, rather than recomputing them — a second copy of that
|
|
209
|
+
# arithmetic is how a timeline starts disagreeing with the video.
|
|
210
|
+
names = ["(opening)"] + [e.name for e in self.trace.events] + ["(final hold)"]
|
|
211
|
+
out, at = [], 0.0
|
|
212
|
+
for name, length in zip(names, self.durations):
|
|
213
|
+
out.append((round(at, 3), length, name))
|
|
214
|
+
at += length
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _find_encoder() -> None:
|
|
219
|
+
"""Make sure the engine can find an ffmpeg to run.
|
|
220
|
+
|
|
221
|
+
Rendering needs ffmpeg, which pip cannot install. Rather than let
|
|
222
|
+
`pip install codimate` succeed and then fail on someone's first render —
|
|
223
|
+
the worst moment to learn about a prerequisite — fall back to the static
|
|
224
|
+
build that ships with `imageio-ffmpeg`.
|
|
225
|
+
|
|
226
|
+
A system ffmpeg is preferred: it is usually newer, hardware-accelerated,
|
|
227
|
+
and the one the author already expects to be used. The bundled copy is a
|
|
228
|
+
safety net, not the default.
|
|
229
|
+
|
|
230
|
+
Silent when nothing is found; the engine raises its own error naming both
|
|
231
|
+
the install and the override.
|
|
232
|
+
"""
|
|
233
|
+
import os
|
|
234
|
+
import shutil
|
|
235
|
+
|
|
236
|
+
if os.environ.get("CODIMATE_FFMPEG") or shutil.which("ffmpeg"):
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
import imageio_ffmpeg
|
|
241
|
+
except ImportError:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
os.environ["CODIMATE_FFMPEG"] = imageio_ffmpeg.get_ffmpeg_exe()
|
|
246
|
+
except Exception: # noqa: BLE001 — a broken fallback must not mask the real error
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def explain(
|
|
251
|
+
*,
|
|
252
|
+
trace: Trace,
|
|
253
|
+
view: View,
|
|
254
|
+
motion: "list[Rule] | None" = None,
|
|
255
|
+
timing: "Timing | None" = None,
|
|
256
|
+
) -> Explanation:
|
|
257
|
+
"""Gather an algorithm, a view and a timing into something renderable.
|
|
258
|
+
|
|
259
|
+
``trace`` is what a ``@cm.trace()``-marked function returns: the moments
|
|
260
|
+
your algorithm passed through. ``view`` is called once per moment and
|
|
261
|
+
returns the picture of it. ``motion`` and ``timing`` are optional —
|
|
262
|
+
without them every shape travels in a straight line and every event lasts
|
|
263
|
+
the same.
|
|
264
|
+
|
|
265
|
+
cm.explain(trace=flip(tally), view=view).render("results/coins.mp4")
|
|
266
|
+
|
|
267
|
+
Nothing is computed here; the work happens in :meth:`Explanation.render`.
|
|
268
|
+
"""
|
|
269
|
+
return Explanation(trace=trace, view=view, motion=motion, timing=timing)
|
codimate/layout.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Where things sit: the canvas, Slots, and the helpers that divide it up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ponytail: module-level so a view function can lay out without being handed a
|
|
9
|
+
# canvas. `render()` reads the same values, so there is one source of truth.
|
|
10
|
+
_CANVAS = [1280.0, 720.0]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def measure(text: str, size: float = 16.0) -> tuple[float, float]:
|
|
14
|
+
"""How wide and tall ``text`` will be at ``size``: ``(w, h)``.
|
|
15
|
+
|
|
16
|
+
For drawing a box around a label without guessing::
|
|
17
|
+
|
|
18
|
+
w, h = cm.measure(label, size=30)
|
|
19
|
+
scene.rect("box", x=x, y=y, w=w + 24, h=h + 12, radius=6)
|
|
20
|
+
scene.text("label", label, x=x, y=y, size=30)
|
|
21
|
+
|
|
22
|
+
Measured by the engine with the real fonts, including fallback, so it is
|
|
23
|
+
right for Khmer and anything else that is not plain ASCII — which is why
|
|
24
|
+
estimating ``len(text) * size * k`` is not good enough.
|
|
25
|
+
|
|
26
|
+
The height is the line height, the same for "cat" and "Qgy", so a row of
|
|
27
|
+
boxes lines up instead of jittering with whatever letters it holds.
|
|
28
|
+
"""
|
|
29
|
+
from . import _codimate # imported here so the pure Python is testable
|
|
30
|
+
|
|
31
|
+
return _codimate.measure(str(text), float(size))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ngon(sides: int, r: float, at=(0.0, 0.0), turn: float = 0.0) -> list:
|
|
35
|
+
"""The corners of a regular polygon, for :meth:`Scene.polygon`.
|
|
36
|
+
|
|
37
|
+
scene.polygon("tri", cm.ngon(3, r=60, at=(640, 360)))
|
|
38
|
+
|
|
39
|
+
A triangle is three sides, a hexagon six. ``turn`` rotates it in degrees —
|
|
40
|
+
the first corner otherwise points straight up.
|
|
41
|
+
|
|
42
|
+
Returns points rather than drawing, so it composes: you can shift them,
|
|
43
|
+
hand them to `polygon`, or measure them yourself.
|
|
44
|
+
"""
|
|
45
|
+
import math
|
|
46
|
+
|
|
47
|
+
if sides < 3:
|
|
48
|
+
raise ValueError(f"a polygon needs at least 3 sides, got {sides}")
|
|
49
|
+
step = 2 * math.pi / sides
|
|
50
|
+
start = math.radians(turn) - math.pi / 2
|
|
51
|
+
return [
|
|
52
|
+
(at[0] + r * math.cos(start + step * i), at[1] + r * math.sin(start + step * i))
|
|
53
|
+
for i in range(sides)
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def star(points: int, r: float, inner: float = None, at=(0.0, 0.0),
|
|
58
|
+
turn: float = 0.0) -> list:
|
|
59
|
+
"""The corners of a star, for :meth:`Scene.polygon`.
|
|
60
|
+
|
|
61
|
+
scene.polygon("s", cm.star(5, r=80, at=(640, 360)), color="yellow")
|
|
62
|
+
|
|
63
|
+
``inner`` is the radius of the valleys; it defaults to a proportion that
|
|
64
|
+
looks like a star rather than a gear.
|
|
65
|
+
"""
|
|
66
|
+
import math
|
|
67
|
+
|
|
68
|
+
inner = r * 0.42 if inner is None else inner
|
|
69
|
+
step = math.pi / points
|
|
70
|
+
start = math.radians(turn) - math.pi / 2
|
|
71
|
+
return [
|
|
72
|
+
(
|
|
73
|
+
at[0] + (r if i % 2 == 0 else inner) * math.cos(start + step * i),
|
|
74
|
+
at[1] + (r if i % 2 == 0 else inner) * math.sin(start + step * i),
|
|
75
|
+
)
|
|
76
|
+
for i in range(points * 2)
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def measure_math(latex: str, size: float = 16.0) -> tuple[float, float]:
|
|
81
|
+
"""How wide and tall a LaTeX formula will be at ``size``: ``(w, h)``.
|
|
82
|
+
|
|
83
|
+
The counterpart of :func:`measure`, so a formula can be laid out beside
|
|
84
|
+
words — a caption that mixes prose and mathematics needs both.
|
|
85
|
+
"""
|
|
86
|
+
from . import _codimate
|
|
87
|
+
|
|
88
|
+
return _codimate.measure_formula(str(latex), float(size))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def canvas(w: float, h: float) -> None:
|
|
92
|
+
"""Set the size of the video. Defaults to 1280x720."""
|
|
93
|
+
_CANVAS[:] = [float(w), float(h)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def width() -> float:
|
|
97
|
+
"""The canvas width. ``cm.width() / 2`` is the horizontal centre."""
|
|
98
|
+
return _CANVAS[0]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def height() -> float:
|
|
102
|
+
"""The canvas height."""
|
|
103
|
+
return _CANVAS[1]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Place:
|
|
108
|
+
"""Where a shape goes, as one value.
|
|
109
|
+
|
|
110
|
+
Built by :func:`at`. Exists so a shape takes one placement argument
|
|
111
|
+
instead of six — the six were one idea wearing a disguise.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
x: "float | None" = None
|
|
115
|
+
y: "float | None" = None
|
|
116
|
+
top: "float | None" = None
|
|
117
|
+
bottom: "float | None" = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def at(x=None, y=None, top=None, bottom=None) -> Place:
|
|
121
|
+
"""A place, for a shape's ``at=``.
|
|
122
|
+
|
|
123
|
+
scene.rect("bar", h=40, at=cm.at(bottom=0))
|
|
124
|
+
scene.text("l", "hi", at=cm.at(x=col, top=y + 22))
|
|
125
|
+
|
|
126
|
+
Give one value per axis: ``x`` or nothing for horizontal, and ``y``,
|
|
127
|
+
``top`` or ``bottom`` for vertical. A plain ``(x, y)`` or a `Slot` works
|
|
128
|
+
wherever a Place does, so you only need this for edges.
|
|
129
|
+
"""
|
|
130
|
+
if y is not None and (top is not None or bottom is not None):
|
|
131
|
+
raise ValueError("give one of y=, top= or bottom=")
|
|
132
|
+
return Place(x=x, y=y, top=top, bottom=bottom)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class Slot:
|
|
137
|
+
"""A place to put something. Not a shape — nothing draws a Slot.
|
|
138
|
+
|
|
139
|
+
`row` and `column` hand you these; you rarely build one.
|
|
140
|
+
|
|
141
|
+
- `x`, `y` — its centre
|
|
142
|
+
- `w`, `h` — its size
|
|
143
|
+
- `left`, `right`, `top`, `bottom` — its edges
|
|
144
|
+
- `anchor` — which edge things placed here line up on
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
x: float
|
|
148
|
+
y: float
|
|
149
|
+
w: float
|
|
150
|
+
h: float
|
|
151
|
+
anchor: str = "center"
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def left(self) -> float:
|
|
155
|
+
return self.x - self.w / 2
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def right(self) -> float:
|
|
159
|
+
return self.x + self.w / 2
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def top(self) -> float:
|
|
163
|
+
return self.y - self.h / 2
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def bottom(self) -> float:
|
|
167
|
+
return self.y + self.h / 2
|
|
168
|
+
|
|
169
|
+
def point(self, anchor: "str | None" = None) -> "tuple[float, float]":
|
|
170
|
+
"""The single point this Slot anchors things at."""
|
|
171
|
+
anchor = anchor or self.anchor
|
|
172
|
+
if anchor == "center":
|
|
173
|
+
return (self.x, self.y)
|
|
174
|
+
if anchor == "bottom":
|
|
175
|
+
return (self.x, self.bottom)
|
|
176
|
+
if anchor == "top":
|
|
177
|
+
return (self.x, self.top)
|
|
178
|
+
raise ValueError(f"unknown anchor {anchor!r} — use center, top or bottom")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _within(box) -> "Slot":
|
|
182
|
+
"""The region a run of Slots divides up — the whole canvas by default."""
|
|
183
|
+
if box is not None:
|
|
184
|
+
return box
|
|
185
|
+
return Slot(x=width() / 2, y=height() / 2, w=width(), h=height())
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _spread(items, gap, size, extent, centre):
|
|
189
|
+
"""Evenly space things of `size` along one axis, centred on `centre`.
|
|
190
|
+
|
|
191
|
+
The shared half of `row` and `column`: normalise the input, pick a default
|
|
192
|
+
size that fills most of `extent`, and lay out the positions.
|
|
193
|
+
"""
|
|
194
|
+
sequence = list(range(items)) if isinstance(items, int) else list(items)
|
|
195
|
+
count = len(sequence)
|
|
196
|
+
if count == 0:
|
|
197
|
+
return [], [], 0.0
|
|
198
|
+
|
|
199
|
+
if size is None:
|
|
200
|
+
# Fill 70% of the canvas by default, so a run always looks deliberate.
|
|
201
|
+
size = max((extent * 0.7 - gap * (count - 1)) / count, 1.0)
|
|
202
|
+
|
|
203
|
+
span = count * size + (count - 1) * gap
|
|
204
|
+
first = centre - span / 2 + size / 2
|
|
205
|
+
return sequence, [first + i * (size + gap) for i in range(count)], size
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _size(size):
|
|
209
|
+
"""A Slot size: one number for both sides, or `(w, h)`."""
|
|
210
|
+
if size is None:
|
|
211
|
+
return None, None
|
|
212
|
+
if isinstance(size, (int, float)):
|
|
213
|
+
return float(size), None
|
|
214
|
+
return size
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def row(
|
|
218
|
+
items,
|
|
219
|
+
*,
|
|
220
|
+
gap: float = 40.0,
|
|
221
|
+
size=None,
|
|
222
|
+
at: "Place | None" = None,
|
|
223
|
+
within: "Slot | None" = None,
|
|
224
|
+
):
|
|
225
|
+
"""One Slot per item, evenly spaced and centred on the canvas.
|
|
226
|
+
|
|
227
|
+
for slot, item in cm.row(values, gap=40, size=190):
|
|
228
|
+
...
|
|
229
|
+
|
|
230
|
+
``size`` is one number for the Slot's width (its height follows), or
|
|
231
|
+
``(w, h)`` for both.
|
|
232
|
+
|
|
233
|
+
A row lays things out on a shared baseline, so its Slots anchor at
|
|
234
|
+
bottom-centre — hand one straight to ``scene.group()``.
|
|
235
|
+
|
|
236
|
+
Pass ``within=slot`` to divide up part of the canvas instead of all of it,
|
|
237
|
+
so a chart can live in its own corner without any arithmetic of yours.
|
|
238
|
+
|
|
239
|
+
Yields ``(slot, item)`` pairs, or bare Slots if you passed a count.
|
|
240
|
+
"""
|
|
241
|
+
box = _within(within)
|
|
242
|
+
place = at or Place()
|
|
243
|
+
w, h = _size(size)
|
|
244
|
+
sequence, xs, w = _spread(items, gap, w, box.w, box.x)
|
|
245
|
+
if h is None:
|
|
246
|
+
h = w
|
|
247
|
+
# A row sits on a baseline near the bottom of its box unless told otherwise.
|
|
248
|
+
floor = box.bottom - box.h * 0.22 if place.bottom is None else place.bottom
|
|
249
|
+
y = place.y if place.y is not None else floor - h / 2
|
|
250
|
+
|
|
251
|
+
for x, item in zip(xs, sequence):
|
|
252
|
+
slot = Slot(x=x, y=y, w=w, h=h, anchor="bottom")
|
|
253
|
+
yield slot if isinstance(items, int) else (slot, item)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def column(
|
|
257
|
+
items,
|
|
258
|
+
*,
|
|
259
|
+
gap: float = 40.0,
|
|
260
|
+
size=None,
|
|
261
|
+
at: "Place | None" = None,
|
|
262
|
+
within: "Slot | None" = None,
|
|
263
|
+
):
|
|
264
|
+
"""One Slot per item, stacked vertically and centred on the canvas.
|
|
265
|
+
|
|
266
|
+
for slot in cm.column(4, gap=40, at=cm.at(x=640)):
|
|
267
|
+
...
|
|
268
|
+
|
|
269
|
+
A column stacks things around a centre line, so its Slots anchor at their
|
|
270
|
+
centre. ``x`` places the column horizontally; it defaults to mid-canvas.
|
|
271
|
+
|
|
272
|
+
Pass ``within=slot`` to stack inside part of the canvas instead of all
|
|
273
|
+
of it.
|
|
274
|
+
|
|
275
|
+
Yields ``(slot, item)`` pairs, or bare Slots if you passed a count.
|
|
276
|
+
"""
|
|
277
|
+
box = _within(within)
|
|
278
|
+
place = at or Place()
|
|
279
|
+
w, h = _size(size)
|
|
280
|
+
centre = box.y if place.y is None else place.y
|
|
281
|
+
sequence, ys, h = _spread(items, gap, h, box.h, centre)
|
|
282
|
+
if w is None:
|
|
283
|
+
w = h
|
|
284
|
+
x = box.x if place.x is None else place.x
|
|
285
|
+
|
|
286
|
+
for cy, item in zip(ys, sequence):
|
|
287
|
+
slot = Slot(x=x, y=cy, w=w, h=h, anchor="center")
|
|
288
|
+
yield slot if isinstance(items, int) else (slot, item)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
_UNSET = object()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _resolve(given, half, names, default=_UNSET):
|
|
295
|
+
"""Turn whichever anchor was given into a centre coordinate."""
|
|
296
|
+
centre, low, high = given
|
|
297
|
+
given = [v for v in given if v is not None]
|
|
298
|
+
if len(given) > 1:
|
|
299
|
+
raise ValueError(f"give only one of {', '.join(f'{n}=' for n in names)}")
|
|
300
|
+
if not given:
|
|
301
|
+
if default is _UNSET:
|
|
302
|
+
raise ValueError(f"give one of {', '.join(f'{n}=' for n in names)}")
|
|
303
|
+
return float(default)
|
|
304
|
+
if centre is not None:
|
|
305
|
+
return float(centre)
|
|
306
|
+
if low is not None:
|
|
307
|
+
return float(low) + half
|
|
308
|
+
return float(high) - half
|