Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ All notable changes to AInotate.
- `--privacy auto` without an OCR engine says how to fix it: install one, or use `--privacy off`
(with rect coordinates when marks use text targets) (#2).

Thanks to @testy-cool for #1 and #2.
### Added
- Looks: other packages can supply a look for the marks (entry point group `ainotate.looks`,
a subclass of `ainotate.render.Look`), picked with `"look"` in the spec, `--look`, the MCP
`look` argument, `AINOTATE_LOOK` or `look` in config.toml. The built-in look stays the default
and renders pixel-identical. A look that fails to load falls back to the default with a
warning (#3).

Thanks to @testy-cool for #1, #2 and #3.

## 0.1.7 - 2026-10-10

Expand Down
7 changes: 4 additions & 3 deletions skills/ainotate/references/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Everything the renderer accepts. `SKILL.md` has the workflow; this file is for l
| `crop_pad` | margin around the marks for `"auto"` and `"tight"` (default 260 image px at 2000px width) |
| `dim` | spotlight darkness 0-1 (default 0.55) |
| `arrow_style` | `"skitch"` (default, tapered), `"straight"`, `"curved"`, or `"line"` |
| `look` | `"default"`, or a look from an installed package (entry point group `ainotate.looks`, a subclass of `ainotate.render.Look`); also `--look` and the MCP `look` argument |
| `frame` | backdrop and window chrome, see below |
| `privacy` | `"off"` (default for images), `"auto"` (OCR the image, redact findings) or an object |

Expand Down Expand Up @@ -149,9 +150,9 @@ explains it below, and can use `**bold**`, `` `code` `` and lines starting with

Final images: `<output_dir>/<prefix> <YYYY-MM-DD at HH.MM.SS> <name>.png`, plus an identical
copy in `backup_dir` if set. Never overwrites (adds " (2)"). Configuration, highest first:
env `AINOTATE_OUTPUT_DIR`, `AINOTATE_BACKUP_DIR`, `AINOTATE_PREFIX`; then
`~/.config/ainotate/config.toml` keys `output_dir`, `backup_dir`, `prefix`; then defaults
`~/Pictures/AInotate`, no backup, prefix `AInotate`.
env `AINOTATE_OUTPUT_DIR`, `AINOTATE_BACKUP_DIR`, `AINOTATE_PREFIX`, `AINOTATE_LOOK`; then
`~/.config/ainotate/config.toml` keys `output_dir`, `backup_dir`, `prefix`, `look`; then defaults
`~/Pictures/AInotate`, no backup, prefix `AInotate`, look `default`. A spec's `look` beats both.

## More patterns

Expand Down
3 changes: 3 additions & 0 deletions src/ainotate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def _result_args(privacy_default):
return [A("spec", help="spec.json, inline JSON, or - for stdin"),
A("--draft", "--no-save", dest="draft", action="store_true", help="write to a temp dir"),
A("--privacy", choices=("auto", "off"), help=f"solid redaction (default: {privacy_default})"),
A("--look", help="mark look: default or an installed one (default: AINOTATE_LOOK, config.toml)"),
A("--copy", action="store_true", help="also put the image on the clipboard"),
A("--json", action="store_true", help="print JSON status on stdout"),
A("--debug", action="store_true", help="draw target/label boxes; implies --draft")]
Expand All @@ -147,6 +148,8 @@ def _spec_with_flags(a):
spec = _load_spec(a.spec)
if a.privacy and isinstance(spec, dict):
spec["privacy"] = a.privacy
if a.look and isinstance(spec, dict):
spec["look"] = a.look
return spec


Expand Down
10 changes: 6 additions & 4 deletions src/ainotate/marks.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,14 @@ def _shape(d, box, shape, radius, **kw):
d.rounded_rectangle(box, radius=radius, **kw)


def draw_loupe(layer, page, sample, box, shape, color, u, path=None):
def draw_loupe(layer, page, sample, box, shape, color, u, path=None, line=None):
"""Source outline, connector and the loupe itself (zoomed page pixels, white rim, color ring).
`page` is the image before any mark is drawn (redactions and blur already applied). `path`: a
curved connector from the loupe to the sample (routed around page content), else straight."""
curved connector from the loupe to the sample (routed around page content), else straight.
`line`: width of the connector, source outline and color ring (default from u)."""
lw, lh = int(box[2] - box[0]), int(box[3] - box[1])
line = max(2, round(0.13 * u))
ring = line
line = line or max(2, round(0.13 * u))
rad_src = min(0.6 * u, (sample[3] - sample[1]) / 2)
rad_lp = min(1.3 * u, min(lw, lh) / 4)
# connector between the facing edges (drawn first: the loupe and outline cover its ends)
Expand Down Expand Up @@ -136,7 +138,7 @@ def draw_loupe(layer, page, sample, box, shape, color, u, path=None):
mask = _aa_mask((lw, lh), lambda d, S: _shape(d, [0, 0, lw * S - 1, lh * S - 1], shape, rad_lp * S, fill=255))
content.putalpha(mask)
layer.alpha_composite(content, dest=(int(box[0]), int(box[1])))
rim, ring = max(3, round(0.3 * u)), max(2, round(0.1 * u))
rim, ring = max(3, round(0.3 * u)), ring or max(2, round(0.1 * u))
inner = grow(box, -rim / 2)
aa_draw(layer, box, "white", lambda d, T, S: _shape(d, T(inner), shape, (rad_lp - rim / 2) * S,
outline="white", width=rim * S))
Expand Down
18 changes: 12 additions & 6 deletions src/ainotate/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ def _spec(spec) -> dict:
return spec


def _with_look(spec, look):
spec = _spec(spec)
return dict(spec, look=look) if look and isinstance(spec, dict) else spec


# ---------------------------------------------------------------- annotate

def _annotate(spec, draft: bool, debug: bool, tool: str) -> types.CallToolResult:
Expand All @@ -120,22 +125,23 @@ def _annotate(spec, draft: bool, debug: bool, tool: str) -> types.CallToolResult


@mcp.tool()
async def annotate(spec: Union[dict, str], draft: bool = True) -> types.CallToolResult:
async def annotate(spec: Union[dict, str], draft: bool = True, look: Optional[str] = None) -> types.CallToolResult:
"""Render an annotation spec onto its `input` image. Draft by default (temp dir): check the
returned preview, then call again with draft=false to save to the output folder.
spec: {"input": path, "scale": 1|2.., "crop": "auto"|[x1,y1,x2,y2], "name": "...",
"marks": [{"type": "step"|"box"|"arrow"|"highlight"|"spotlight"|"redact"|"text"|...,
"rect": {x,y,w,h} or [x1,y1,x2,y2], "label": "...", "n": 1, "color": "look|bad|good|info"}]}.
Every coordinate in one unit; scale converts it to image px. Full reference: ainotate://spec.
look: sets the spec's "look" (default, or a look from an installed package).
Returns paths, size, warnings, legend (step numbers + labels), redactions and a preview."""
return await _run("annotate", lambda: _annotate(spec, draft, False, "annotate"))
return await _run("annotate", lambda: _annotate(_with_look(spec, look), draft, False, "annotate"))


@mcp.tool()
async def preview(spec: Union[dict, str]) -> types.CallToolResult:
async def preview(spec: Union[dict, str], look: Optional[str] = None) -> types.CallToolResult:
"""Draft render with the debug overlay (cyan target rects, magenta label boxes); saves
nothing permanent. Use it to check placement before annotate."""
return await _run("preview", lambda: _annotate(spec, True, True, "preview"))
return await _run("preview", lambda: _annotate(_with_look(spec, look), True, True, "preview"))


# ---------------------------------------------------------------- web
Expand Down Expand Up @@ -180,7 +186,7 @@ def work():


@mcp.tool()
async def shoot(spec: Union[dict, str], draft: bool = True) -> types.CallToolResult:
async def shoot(spec: Union[dict, str], draft: bool = True, look: Optional[str] = None) -> types.CallToolResult:
"""One call from URL to annotated image: spec = render spec without input/scale, plus
url (or cdp_url + page_url_contains), preset, actions, and marks with "target": {...}
(same forms as capture_web) instead of "rect". Draft by default: check the preview, then
Expand All @@ -196,7 +202,7 @@ def work():
"fix": "Use capture_web(targets=...) and then annotate(spec) with the returned "
"rects and scale."}}, error=True)
raise
res = _shoot(_spec(spec), draft=draft)
res = _shoot(_with_look(spec, look), draft=draft)
paths = [str(p) for p in res.paths]
jpeg, (w, h) = preview_jpeg(paths[0])
cap = getattr(res, "capture", None)
Expand Down
15 changes: 15 additions & 0 deletions src/ainotate/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ def pick(env, key, default):
return Config(Path(out).expanduser(), Path(bak).expanduser() if bak else None, prefix)


def default_look() -> str:
"""The look for a spec without "look": env AINOTATE_LOOK > config.toml `look` > "default"."""
from .spec import SpecError, check_look
look = os.environ.get("AINOTATE_LOOK") or None
where = "AINOTATE_LOOK"
if look is None:
try:
look = _read_config_file(CONFIG_PATH).get("look") or "default"
except OutputError as e:
raise SpecError(str(e))
where = f"`look` in {CONFIG_PATH}"
check_look(look, where)
return look


def safe_component(value: str, what: str = "name") -> str:
"""`value` if it is one plain file-name component: no path separators, no '..', no control
characters, not '.'; else OutputError (a bad prefix must not write outside the folder)."""
Expand Down
Loading
Loading