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 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,4 @@
1
+ from clickcast.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,5 @@
1
+ """Frame annotator — overlay ripples, labels, cursor trail, progress bar."""
2
+
3
+ from clickcast.annotate.annotator import AnnotateConfig, Annotator
4
+
5
+ __all__ = ["AnnotateConfig", "Annotator"]
@@ -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,5 @@
1
+ """Frame capture pipeline."""
2
+
3
+ from clickcast.capture.recorder import FrameRef, Recorder
4
+
5
+ __all__ = ["FrameRef", "Recorder"]
@@ -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)