clickcast 0.1.0__py3-none-any.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.
- clickcast/__init__.py +12 -0
- clickcast/__main__.py +4 -0
- clickcast/annotate/__init__.py +5 -0
- clickcast/annotate/annotator.py +251 -0
- clickcast/annotate/fonts/DejaVuSans-LICENSE.txt +78 -0
- clickcast/annotate/fonts/DejaVuSans.ttf +0 -0
- clickcast/capture/__init__.py +5 -0
- clickcast/capture/recorder.py +194 -0
- clickcast/cli.py +743 -0
- clickcast/config/__init__.py +19 -0
- clickcast/config/config.py +249 -0
- clickcast/core/__init__.py +0 -0
- clickcast/core/actions.py +230 -0
- clickcast/core/session.py +181 -0
- clickcast/discovery/__init__.py +5 -0
- clickcast/discovery/discovery.py +233 -0
- clickcast/encode/__init__.py +5 -0
- clickcast/encode/encoder.py +240 -0
- clickcast/feedback/__init__.py +42 -0
- clickcast/feedback/builder.py +126 -0
- clickcast/feedback/collector.py +79 -0
- clickcast/feedback/models.py +86 -0
- clickcast/feedback/schema/v1.json +320 -0
- clickcast/py.typed +0 -0
- clickcast/reel.py +436 -0
- clickcast/scenario/__init__.py +21 -0
- clickcast/scenario/scenario.py +335 -0
- clickcast-0.1.0.dist-info/METADATA +695 -0
- clickcast-0.1.0.dist-info/RECORD +32 -0
- clickcast-0.1.0.dist-info/WHEEL +4 -0
- clickcast-0.1.0.dist-info/entry_points.txt +2 -0
- clickcast-0.1.0.dist-info/licenses/LICENSE +201 -0
clickcast/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""clickcast — drive a browser, return a reel + AI-readable feedback sidecar."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from clickcast.reel import AsyncReel, Reel, discover
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("clickcast")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
|
11
|
+
|
|
12
|
+
__all__ = ["AsyncReel", "Reel", "__version__", "discover"]
|
clickcast/__main__.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Frame annotator — composite click ripples, labels, cursor trail, progress bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
12
|
+
|
|
13
|
+
__all__ = ["AnnotateConfig", "Annotator"]
|
|
14
|
+
|
|
15
|
+
_BUNDLED_FONT = "DejaVuSans.ttf"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class AnnotateConfig:
|
|
20
|
+
"""Toggles + tunables for every annotation layer.
|
|
21
|
+
|
|
22
|
+
Every field is safe to override in isolation; defaults produce a
|
|
23
|
+
legible-on-any-background overlay at ~1280x800.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Layer toggles ------------------------------------------------------
|
|
27
|
+
clicks: bool = True
|
|
28
|
+
labels: bool = True
|
|
29
|
+
cursor: bool = True
|
|
30
|
+
progress: bool = True
|
|
31
|
+
|
|
32
|
+
# Font ---------------------------------------------------------------
|
|
33
|
+
font_path: str | Path | None = None # None → bundled DejaVuSans.ttf
|
|
34
|
+
font_size: int = 20
|
|
35
|
+
|
|
36
|
+
# Label bar ----------------------------------------------------------
|
|
37
|
+
label_max_chars: int = 60
|
|
38
|
+
label_padding_x: int = 24
|
|
39
|
+
label_padding_y: int = 12
|
|
40
|
+
label_bg_color: tuple[int, int, int, int] = (20, 20, 20, 192)
|
|
41
|
+
label_fg_color: tuple[int, int, int, int] = (255, 255, 255, 255)
|
|
42
|
+
label_radius: int = 8
|
|
43
|
+
label_position: Literal["top", "bottom"] = "bottom"
|
|
44
|
+
label_margin: int = 32
|
|
45
|
+
|
|
46
|
+
# Click ripple -------------------------------------------------------
|
|
47
|
+
ripple_stages: int = 3
|
|
48
|
+
ripple_radius_min: int = 12
|
|
49
|
+
ripple_radius_max: int = 48
|
|
50
|
+
ripple_color: tuple[int, int, int] = (255, 255, 255)
|
|
51
|
+
ripple_alpha_start: int = 128
|
|
52
|
+
ripple_width: int = 3
|
|
53
|
+
|
|
54
|
+
# Cursor + trail -----------------------------------------------------
|
|
55
|
+
cursor_color: tuple[int, int, int, int] = (255, 220, 100, 240)
|
|
56
|
+
cursor_size: int = 14
|
|
57
|
+
cursor_trail_length: int = 6
|
|
58
|
+
cursor_trail_alpha_max: int = 160
|
|
59
|
+
|
|
60
|
+
# Progress bar -------------------------------------------------------
|
|
61
|
+
progress_height: int = 4
|
|
62
|
+
progress_color: tuple[int, int, int, int] = (100, 200, 255, 220)
|
|
63
|
+
progress_bg_color: tuple[int, int, int, int] = (255, 255, 255, 40)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _load_font(config: AnnotateConfig) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
67
|
+
if config.font_path:
|
|
68
|
+
return ImageFont.truetype(str(config.font_path), config.font_size)
|
|
69
|
+
resource = files("clickcast.annotate").joinpath("fonts").joinpath(_BUNDLED_FONT)
|
|
70
|
+
try:
|
|
71
|
+
data = resource.read_bytes()
|
|
72
|
+
except (FileNotFoundError, OSError):
|
|
73
|
+
# Last-resort fallback — bitmap font, tiny. Signals a packaging bug.
|
|
74
|
+
return ImageFont.load_default()
|
|
75
|
+
return ImageFont.truetype(io.BytesIO(data), config.font_size)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Annotator:
|
|
79
|
+
"""Overlay annotations on captured frames.
|
|
80
|
+
|
|
81
|
+
Never mutates the input frame in place — `annotate()` writes to a new
|
|
82
|
+
file (`out_path` if given, otherwise `<stem>.annotated.png` next to the
|
|
83
|
+
input). Cursor trail state is maintained across calls; use
|
|
84
|
+
:meth:`reset_cursor` when starting a new scenario.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, config: AnnotateConfig | None = None) -> None:
|
|
88
|
+
self.config = config or AnnotateConfig()
|
|
89
|
+
self._font = _load_font(self.config)
|
|
90
|
+
self._cursor_history: list[tuple[int, int]] = []
|
|
91
|
+
|
|
92
|
+
def reset_cursor(self) -> None:
|
|
93
|
+
self._cursor_history.clear()
|
|
94
|
+
|
|
95
|
+
def annotate(
|
|
96
|
+
self,
|
|
97
|
+
frame_path: str | Path,
|
|
98
|
+
*,
|
|
99
|
+
out_path: str | Path | None = None,
|
|
100
|
+
step_index: int = 0,
|
|
101
|
+
total_steps: int = 1,
|
|
102
|
+
label: str | None = None,
|
|
103
|
+
cursor_xy: tuple[int, int] | None = None,
|
|
104
|
+
click_at: tuple[int, int] | None = None,
|
|
105
|
+
ripple_stage: int = 0,
|
|
106
|
+
) -> Path:
|
|
107
|
+
"""Composite the enabled layers onto ``frame_path``; return output Path.
|
|
108
|
+
|
|
109
|
+
``ripple_stage`` is 1..``ripple_stages`` for the N frames after a
|
|
110
|
+
click; pass 0 when there was no click on this frame.
|
|
111
|
+
"""
|
|
112
|
+
src = Path(frame_path)
|
|
113
|
+
dst = Path(out_path) if out_path else src.with_name(f"{src.stem}.annotated.png")
|
|
114
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
|
|
116
|
+
with Image.open(src) as base:
|
|
117
|
+
canvas = base.convert("RGBA")
|
|
118
|
+
|
|
119
|
+
if cursor_xy is not None:
|
|
120
|
+
self._cursor_history.append(cursor_xy)
|
|
121
|
+
history_cap = max(self.config.cursor_trail_length + 1, 1)
|
|
122
|
+
while len(self._cursor_history) > history_cap:
|
|
123
|
+
self._cursor_history.pop(0)
|
|
124
|
+
|
|
125
|
+
if self.config.progress:
|
|
126
|
+
self._draw_progress(canvas, step_index, total_steps)
|
|
127
|
+
if self.config.clicks and click_at is not None and ripple_stage > 0:
|
|
128
|
+
self._draw_ripple(canvas, click_at, ripple_stage)
|
|
129
|
+
if self.config.cursor and self._cursor_history:
|
|
130
|
+
self._draw_cursor(canvas)
|
|
131
|
+
if self.config.labels and label:
|
|
132
|
+
self._draw_label(canvas, label)
|
|
133
|
+
|
|
134
|
+
canvas.convert("RGB").save(dst, format="PNG")
|
|
135
|
+
return dst
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Layers
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _draw_progress(self, canvas: Image.Image, step_index: int, total_steps: int) -> None:
|
|
142
|
+
cfg = self.config
|
|
143
|
+
w, h = canvas.size
|
|
144
|
+
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
145
|
+
od = ImageDraw.Draw(overlay)
|
|
146
|
+
y = h - cfg.progress_height
|
|
147
|
+
od.rectangle([0, y, w, h], fill=cfg.progress_bg_color)
|
|
148
|
+
frac = (step_index + 1) / max(total_steps, 1)
|
|
149
|
+
od.rectangle([0, y, int(w * frac), h], fill=cfg.progress_color)
|
|
150
|
+
canvas.alpha_composite(overlay)
|
|
151
|
+
|
|
152
|
+
def _draw_ripple(
|
|
153
|
+
self,
|
|
154
|
+
canvas: Image.Image,
|
|
155
|
+
at: tuple[int, int],
|
|
156
|
+
stage: int,
|
|
157
|
+
) -> None:
|
|
158
|
+
cfg = self.config
|
|
159
|
+
# stage 1..N — radius grows, alpha fades
|
|
160
|
+
t = min(1.0, stage / max(cfg.ripple_stages, 1))
|
|
161
|
+
radius = int(cfg.ripple_radius_min + t * (cfg.ripple_radius_max - cfg.ripple_radius_min))
|
|
162
|
+
alpha = max(0, int(cfg.ripple_alpha_start * (1 - t)))
|
|
163
|
+
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
164
|
+
od = ImageDraw.Draw(overlay)
|
|
165
|
+
od.ellipse(
|
|
166
|
+
[at[0] - radius, at[1] - radius, at[0] + radius, at[1] + radius],
|
|
167
|
+
outline=(*cfg.ripple_color, alpha),
|
|
168
|
+
width=cfg.ripple_width,
|
|
169
|
+
)
|
|
170
|
+
canvas.alpha_composite(overlay)
|
|
171
|
+
|
|
172
|
+
def _draw_cursor(self, canvas: Image.Image) -> None:
|
|
173
|
+
cfg = self.config
|
|
174
|
+
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
175
|
+
od = ImageDraw.Draw(overlay)
|
|
176
|
+
|
|
177
|
+
trail = self._cursor_history[:-1]
|
|
178
|
+
if trail:
|
|
179
|
+
for i, pos in enumerate(trail):
|
|
180
|
+
# Older = fainter
|
|
181
|
+
fade = (i + 1) / len(trail)
|
|
182
|
+
alpha = int(cfg.cursor_trail_alpha_max * fade)
|
|
183
|
+
r = max(2, cfg.cursor_size // 3)
|
|
184
|
+
od.ellipse(
|
|
185
|
+
[pos[0] - r, pos[1] - r, pos[0] + r, pos[1] + r],
|
|
186
|
+
fill=(*cfg.cursor_color[:3], alpha),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
cx, cy = self._cursor_history[-1]
|
|
190
|
+
s = cfg.cursor_size
|
|
191
|
+
od.polygon(
|
|
192
|
+
[
|
|
193
|
+
(cx, cy - s // 2),
|
|
194
|
+
(cx + s // 2, cy),
|
|
195
|
+
(cx, cy + s // 2),
|
|
196
|
+
(cx - s // 2, cy),
|
|
197
|
+
],
|
|
198
|
+
fill=cfg.cursor_color,
|
|
199
|
+
outline=(0, 0, 0, 220),
|
|
200
|
+
)
|
|
201
|
+
canvas.alpha_composite(overlay)
|
|
202
|
+
|
|
203
|
+
def _draw_label(self, canvas: Image.Image, text: str) -> None:
|
|
204
|
+
cfg = self.config
|
|
205
|
+
wrapped = _wrap(text, cfg.label_max_chars)
|
|
206
|
+
|
|
207
|
+
measure = ImageDraw.Draw(canvas)
|
|
208
|
+
bbox = measure.multiline_textbbox((0, 0), wrapped, font=self._font)
|
|
209
|
+
text_w = bbox[2] - bbox[0]
|
|
210
|
+
text_h = bbox[3] - bbox[1]
|
|
211
|
+
|
|
212
|
+
box_w = text_w + 2 * cfg.label_padding_x
|
|
213
|
+
box_h = text_h + 2 * cfg.label_padding_y
|
|
214
|
+
|
|
215
|
+
img_w, img_h = canvas.size
|
|
216
|
+
x = max(0, (img_w - box_w) // 2)
|
|
217
|
+
y = img_h - box_h - cfg.label_margin if cfg.label_position == "bottom" else cfg.label_margin
|
|
218
|
+
|
|
219
|
+
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
220
|
+
od = ImageDraw.Draw(overlay)
|
|
221
|
+
od.rounded_rectangle(
|
|
222
|
+
[x, y, x + box_w, y + box_h],
|
|
223
|
+
radius=cfg.label_radius,
|
|
224
|
+
fill=cfg.label_bg_color,
|
|
225
|
+
)
|
|
226
|
+
# Draw text on the same overlay for correct compositing
|
|
227
|
+
od.multiline_text(
|
|
228
|
+
(x + cfg.label_padding_x, y + cfg.label_padding_y - bbox[1]),
|
|
229
|
+
wrapped,
|
|
230
|
+
font=self._font,
|
|
231
|
+
fill=cfg.label_fg_color,
|
|
232
|
+
)
|
|
233
|
+
canvas.alpha_composite(overlay)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _wrap(text: str, max_chars: int) -> str:
|
|
237
|
+
if len(text) <= max_chars:
|
|
238
|
+
return text
|
|
239
|
+
lines: list[str] = []
|
|
240
|
+
current = ""
|
|
241
|
+
for word in text.split():
|
|
242
|
+
candidate = word if not current else f"{current} {word}"
|
|
243
|
+
if len(candidate) <= max_chars:
|
|
244
|
+
current = candidate
|
|
245
|
+
else:
|
|
246
|
+
if current:
|
|
247
|
+
lines.append(current)
|
|
248
|
+
current = word
|
|
249
|
+
if current:
|
|
250
|
+
lines.append(current)
|
|
251
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
|
2
|
+
Upstream-Name: DejaVu fonts
|
|
3
|
+
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
|
4
|
+
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
|
5
|
+
Source: https://dejavu-fonts.github.io/
|
|
6
|
+
|
|
7
|
+
Files: *
|
|
8
|
+
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
|
9
|
+
Bitstream Vera is a trademark of Bitstream, Inc.
|
|
10
|
+
DejaVu changes are in public domain.
|
|
11
|
+
License: bitstream-vera
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of the fonts accompanying this license ("Fonts") and associated
|
|
14
|
+
documentation files (the "Font Software"), to reproduce and distribute the
|
|
15
|
+
Font Software, including without limitation the rights to use, copy, merge,
|
|
16
|
+
publish, distribute, and/or sell copies of the Font Software, and to permit
|
|
17
|
+
persons to whom the Font Software is furnished to do so, subject to the
|
|
18
|
+
following conditions:
|
|
19
|
+
.
|
|
20
|
+
The above copyright and trademark notices and this permission notice shall
|
|
21
|
+
be included in all copies of one or more of the Font Software typefaces.
|
|
22
|
+
.
|
|
23
|
+
The Font Software may be modified, altered, or added to, and in particular
|
|
24
|
+
the designs of glyphs or characters in the Fonts may be modified and
|
|
25
|
+
additional glyphs or characters may be added to the Fonts, only if the fonts
|
|
26
|
+
are renamed to names not containing either the words "Bitstream" or the word
|
|
27
|
+
"Vera".
|
|
28
|
+
.
|
|
29
|
+
This License becomes null and void to the extent applicable to Fonts or Font
|
|
30
|
+
Software that has been modified and is distributed under the "Bitstream
|
|
31
|
+
Vera" names.
|
|
32
|
+
.
|
|
33
|
+
The Font Software may be sold as part of a larger software package but no
|
|
34
|
+
copy of one or more of the Font Software typefaces may be sold by itself.
|
|
35
|
+
.
|
|
36
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
37
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
|
38
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
|
39
|
+
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
|
40
|
+
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
|
41
|
+
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
|
42
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
|
43
|
+
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
|
44
|
+
FONT SOFTWARE.
|
|
45
|
+
.
|
|
46
|
+
Except as contained in this notice, the names of Gnome, the Gnome
|
|
47
|
+
Foundation, and Bitstream Inc., shall not be used in advertising or
|
|
48
|
+
otherwise to promote the sale, use or other dealings in this Font Software
|
|
49
|
+
without prior written authorization from the Gnome Foundation or Bitstream
|
|
50
|
+
Inc., respectively. For further information, contact: fonts at gnome dot
|
|
51
|
+
org.
|
|
52
|
+
|
|
53
|
+
Files: debian/*
|
|
54
|
+
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
|
55
|
+
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
|
56
|
+
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
|
57
|
+
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
|
58
|
+
License: GPL-2+
|
|
59
|
+
This program is free software; you can redistribute it
|
|
60
|
+
and/or modify it under the terms of the GNU General Public
|
|
61
|
+
License as published by the Free Software Foundation; either
|
|
62
|
+
version 2 of the License, or (at your option) any later
|
|
63
|
+
version.
|
|
64
|
+
.
|
|
65
|
+
This program is distributed in the hope that it will be
|
|
66
|
+
useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
67
|
+
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
|
68
|
+
PURPOSE. See the GNU General Public License for more
|
|
69
|
+
details.
|
|
70
|
+
.
|
|
71
|
+
You should have received a copy of the GNU General Public
|
|
72
|
+
License along with this package; if not, write to the Free
|
|
73
|
+
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
|
74
|
+
Boston, MA 02110-1301 USA
|
|
75
|
+
.
|
|
76
|
+
On Debian systems, the full text of the GNU General Public
|
|
77
|
+
License version 2 can be found in the file
|
|
78
|
+
/usr/share/common-licenses/GPL-2'.
|
|
Binary file
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Frame capture pipeline — turn a stream of actions into an ordered stream of PNGs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from clickcast.core.actions import ActionResult, BaseStep
|
|
15
|
+
from clickcast.core.session import Session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["FrameRef", "Recorder"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True, frozen=True)
|
|
22
|
+
class FrameRef:
|
|
23
|
+
path: Path
|
|
24
|
+
step_index: int
|
|
25
|
+
sub_index: int
|
|
26
|
+
cursor_xy: tuple[int, int] | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Recorder:
|
|
30
|
+
"""Owns a temp directory of PNG frames and produces a `frames.json` manifest.
|
|
31
|
+
|
|
32
|
+
Usage::
|
|
33
|
+
|
|
34
|
+
with Recorder(fps=12) as rec:
|
|
35
|
+
for step in scenario.steps:
|
|
36
|
+
await rec.pre_action(session)
|
|
37
|
+
result = await execute(step, session)
|
|
38
|
+
await rec.post_action(session, result, step)
|
|
39
|
+
paths = rec.flush() # writes frames.json, returns ordered paths
|
|
40
|
+
encoder.encode(rec.frames_dir, out="tour.gif") # (in #9)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
fps: int = 12,
|
|
47
|
+
default_dwell: float = 1.0,
|
|
48
|
+
keep: bool = False,
|
|
49
|
+
out_dir: Path | str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
if fps <= 0:
|
|
52
|
+
raise ValueError("fps must be positive")
|
|
53
|
+
if default_dwell < 0:
|
|
54
|
+
raise ValueError("default_dwell must be non-negative")
|
|
55
|
+
|
|
56
|
+
self.fps = fps
|
|
57
|
+
self.default_dwell = default_dwell
|
|
58
|
+
self.keep = keep
|
|
59
|
+
self.out_dir: Path = Path(out_dir) if out_dir is not None else Path("frames")
|
|
60
|
+
|
|
61
|
+
self._tmp: tempfile.TemporaryDirectory[str] | None = None
|
|
62
|
+
self._tmp_path: Path | None = None
|
|
63
|
+
self._frames: list[FrameRef] = []
|
|
64
|
+
self._step_index: int = -1
|
|
65
|
+
self._sub_index: int = 0
|
|
66
|
+
self._entered: bool = False
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> Recorder:
|
|
69
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="clickcast-frames-")
|
|
70
|
+
self._tmp_path = Path(self._tmp.name)
|
|
71
|
+
self._entered = True
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(
|
|
75
|
+
self,
|
|
76
|
+
exc_type: type[BaseException] | None,
|
|
77
|
+
exc: BaseException | None,
|
|
78
|
+
tb: TracebackType | None,
|
|
79
|
+
) -> None:
|
|
80
|
+
try:
|
|
81
|
+
if self.keep and self._tmp_path is not None:
|
|
82
|
+
self._persist()
|
|
83
|
+
finally:
|
|
84
|
+
if self._tmp is not None:
|
|
85
|
+
self._tmp.cleanup()
|
|
86
|
+
self._tmp = None
|
|
87
|
+
self._tmp_path = None
|
|
88
|
+
self._entered = False
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def frames(self) -> list[FrameRef]:
|
|
92
|
+
return list(self._frames)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def frames_dir(self) -> Path:
|
|
96
|
+
if self._tmp_path is None:
|
|
97
|
+
raise RuntimeError("Recorder is not open — use `with Recorder(...) as rec:`")
|
|
98
|
+
return self._tmp_path
|
|
99
|
+
|
|
100
|
+
async def pre_action(self, session: Session) -> Path:
|
|
101
|
+
"""Capture one frame BEFORE the action executes, opening a new step."""
|
|
102
|
+
self._step_index += 1
|
|
103
|
+
self._sub_index = 0
|
|
104
|
+
return await self._capture(session, cursor_xy=None)
|
|
105
|
+
|
|
106
|
+
async def post_action(
|
|
107
|
+
self,
|
|
108
|
+
session: Session,
|
|
109
|
+
result: ActionResult,
|
|
110
|
+
step: BaseStep,
|
|
111
|
+
) -> list[Path]:
|
|
112
|
+
"""Capture the post-state frame + padding copies for `dwell * fps`.
|
|
113
|
+
|
|
114
|
+
The action's own timing does not produce frames — its dwell period is
|
|
115
|
+
held on the post-state so the reel plays smoothly at `fps`.
|
|
116
|
+
"""
|
|
117
|
+
dwell = step.dwell if step.dwell > 0 else self.default_dwell
|
|
118
|
+
n = max(1, round(dwell * self.fps))
|
|
119
|
+
first = await self._capture(session, cursor_xy=result.cursor_xy)
|
|
120
|
+
paths = [first]
|
|
121
|
+
for _ in range(1, n):
|
|
122
|
+
copy_path = self._next_path()
|
|
123
|
+
shutil.copyfile(first, copy_path)
|
|
124
|
+
self._record(copy_path, cursor_xy=result.cursor_xy)
|
|
125
|
+
paths.append(copy_path)
|
|
126
|
+
return paths
|
|
127
|
+
|
|
128
|
+
async def pad(
|
|
129
|
+
self,
|
|
130
|
+
session: Session,
|
|
131
|
+
frames: int,
|
|
132
|
+
cursor_xy: tuple[int, int] | None = None,
|
|
133
|
+
) -> list[Path]:
|
|
134
|
+
"""Force N padding frames of the current page state."""
|
|
135
|
+
if frames <= 0:
|
|
136
|
+
return []
|
|
137
|
+
first = await self._capture(session, cursor_xy=cursor_xy)
|
|
138
|
+
paths = [first]
|
|
139
|
+
for _ in range(1, frames):
|
|
140
|
+
copy_path = self._next_path()
|
|
141
|
+
shutil.copyfile(first, copy_path)
|
|
142
|
+
self._record(copy_path, cursor_xy=cursor_xy)
|
|
143
|
+
paths.append(copy_path)
|
|
144
|
+
return paths
|
|
145
|
+
|
|
146
|
+
def flush(self) -> list[Path]:
|
|
147
|
+
"""Write `frames.json` next to the frames and return their ordered paths."""
|
|
148
|
+
if self._tmp_path is None:
|
|
149
|
+
raise RuntimeError("Recorder is not open — use `with Recorder(...) as rec:`")
|
|
150
|
+
manifest = {
|
|
151
|
+
"fps": self.fps,
|
|
152
|
+
"count": len(self._frames),
|
|
153
|
+
"frames": [
|
|
154
|
+
{
|
|
155
|
+
"path": f.path.name,
|
|
156
|
+
"step_index": f.step_index,
|
|
157
|
+
"sub_index": f.sub_index,
|
|
158
|
+
"cursor_xy": list(f.cursor_xy) if f.cursor_xy is not None else None,
|
|
159
|
+
}
|
|
160
|
+
for f in self._frames
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
(self._tmp_path / "frames.json").write_text(json.dumps(manifest, indent=2))
|
|
164
|
+
return [f.path for f in self._frames]
|
|
165
|
+
|
|
166
|
+
async def _capture(self, session: Session, *, cursor_xy: tuple[int, int] | None) -> Path:
|
|
167
|
+
path = self._next_path()
|
|
168
|
+
await session.screenshot(path=path)
|
|
169
|
+
self._record(path, cursor_xy=cursor_xy)
|
|
170
|
+
return path
|
|
171
|
+
|
|
172
|
+
def _record(self, path: Path, *, cursor_xy: tuple[int, int] | None) -> None:
|
|
173
|
+
self._frames.append(
|
|
174
|
+
FrameRef(
|
|
175
|
+
path=path,
|
|
176
|
+
step_index=self._step_index,
|
|
177
|
+
sub_index=self._sub_index,
|
|
178
|
+
cursor_xy=cursor_xy,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
self._sub_index += 1
|
|
182
|
+
|
|
183
|
+
def _next_path(self) -> Path:
|
|
184
|
+
if self._tmp_path is None:
|
|
185
|
+
raise RuntimeError("Recorder is not open — use `with Recorder(...) as rec:`")
|
|
186
|
+
step_idx = max(self._step_index, 0)
|
|
187
|
+
return self._tmp_path / f"frame-{step_idx:04d}-{self._sub_index:03d}.png"
|
|
188
|
+
|
|
189
|
+
def _persist(self) -> None:
|
|
190
|
+
assert self._tmp_path is not None
|
|
191
|
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
for entry in self._tmp_path.iterdir():
|
|
193
|
+
dst = self.out_dir / entry.name
|
|
194
|
+
shutil.copy2(entry, dst)
|