scanner-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,50 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes to PyPI whenever you publish a GitHub Release.
4
+ # Uses PyPI Trusted Publishing (OIDC) — no API token needs to be stored as a secret.
5
+ # One-time setup on PyPI: create a "pending publisher" for project `scanner-mcp` with
6
+ # Owner: AminHA1248 Repo: scanner-mcp Workflow: publish.yml Environment: pypi
7
+ # (https://pypi.org/manage/account/publishing/)
8
+
9
+ on:
10
+ release:
11
+ types: [published]
12
+ workflow_dispatch: {}
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ build:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: "3.12"
25
+ - name: Build sdist and wheel
26
+ run: |
27
+ python -m pip install --upgrade build
28
+ python -m build
29
+ - name: Check distributions
30
+ run: |
31
+ python -m pip install --upgrade twine
32
+ twine check dist/*
33
+ - uses: actions/upload-artifact@v4
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+
38
+ publish:
39
+ needs: build
40
+ runs-on: ubuntu-latest
41
+ environment: pypi
42
+ permissions:
43
+ id-token: write # required for Trusted Publishing
44
+ steps:
45
+ - uses: actions/download-artifact@v4
46
+ with:
47
+ name: dist
48
+ path: dist/
49
+ - name: Publish to PyPI
50
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,22 @@
1
+ # Virtual environments
2
+ .venv/
3
+ venv/
4
+ env/
5
+
6
+ # Python bytecode / caches
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ .eggs/
11
+ build/
12
+ dist/
13
+
14
+ # Editor / OS
15
+ .vscode/
16
+ .idea/
17
+ .DS_Store
18
+ Thumbs.db
19
+
20
+ # Local scan output (if ever pointed inside the repo)
21
+ Scans/
22
+ *.bak
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Amin Haji-Abolhassani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: scanner-mcp
3
+ Version: 0.1.0
4
+ Summary: Generic MCP server exposing a scan tool to network (eSCL/AirScan) and USB (WIA/SANE/TWAIN) scanners.
5
+ Project-URL: Homepage, https://github.com/AminHA1248/scanner-mcp
6
+ Project-URL: Repository, https://github.com/AminHA1248/scanner-mcp
7
+ Project-URL: Issues, https://github.com/AminHA1248/scanner-mcp/issues
8
+ Author-email: Amin Haji-Abolhassani <amin.h.a@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: airscan,claude,escl,mcp,model-context-protocol,mopria,ocr,sane,scanner,scanning,twain,wia
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Multimedia :: Graphics :: Capture :: Scanners
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: mcp>=1.2.0
28
+ Requires-Dist: pillow>=10.0
29
+ Requires-Dist: zeroconf>=0.132
30
+ Provides-Extra: ocr
31
+ Requires-Dist: pytesseract>=0.3.10; extra == 'ocr'
32
+ Provides-Extra: twain
33
+ Requires-Dist: pytwain>=2.3; (platform_system == 'Windows') and extra == 'twain'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # scanner-mcp
37
+
38
+ ![License: MIT](https://img.shields.io/github/license/AminHA1248/scanner-mcp?color=blue)
39
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)
40
+ ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
41
+ ![MCP](https://img.shields.io/badge/MCP-server-8A2BE2)
42
+ ![Stars](https://img.shields.io/github/stars/AminHA1248/scanner-mcp?style=social)
43
+
44
+ > **Let Claude scan and read paper documents** from any USB or network scanner.
45
+
46
+ <p align="center">
47
+ <img src="https://raw.githubusercontent.com/AminHA1248/scanner-mcp/main/docs/demo.gif" alt="Ask Claude to scan a document; it calls the scan_document tool and reads the page back" width="760">
48
+ </p>
49
+
50
+ A **generic MCP server** that exposes a scanning tool to Claude (and any other MCP
51
+ client). It works with **network scanners** through the standard **eSCL / AirScan /
52
+ Mopria** protocol and with **USB scanners** through the platform driver stack —
53
+ **WIA** on Windows and **SANE** on Linux/macOS. No vendor-specific driver code.
54
+
55
+ When Claude needs to read a paper document, it can call `scan_document`, and the page
56
+ image is returned inline so Claude can read it directly (optionally OCR'd to text, or
57
+ saved as PDF).
58
+
59
+ ## What it exposes
60
+
61
+ | Tool | Purpose |
62
+ |------|---------|
63
+ | `list_scanners` | Discover every scanner reachable from this machine (network + USB). |
64
+ | `scan_document` | Scan a page/stack and return it as inline images, a saved file, and/or OCR text. |
65
+
66
+ Backends are auto-detected and degrade gracefully — the same server runs on any OS and
67
+ lights up whatever scanners it can reach:
68
+
69
+ - **eSCL** (`_uscan._tcp` mDNS) — driverless network MFPs/scanners. *Cross-platform.*
70
+ - **WIA** — USB (and some network) scanners on **Windows**, driven via PowerShell COM.
71
+ - **TWAIN** — Windows scanners that expose only a TWAIN data source (older/pro units
72
+ with no usable WIA driver). Optional; needs `pytwain` + a TWAIN DSM (see below).
73
+ - **SANE** (`scanimage`) — USB/network scanners on **Linux** and **macOS**.
74
+
75
+ ## Install
76
+
77
+ ```bash
78
+ cd scanner-mcp
79
+ python -m venv .venv
80
+ # Windows: .venv\Scripts\activate
81
+ # macOS/Linux: source .venv/bin/activate
82
+ pip install -e .
83
+ # optional OCR support:
84
+ pip install -e ".[ocr]" # also needs the Tesseract binary installed
85
+ # optional TWAIN backend (Windows only):
86
+ pip install -e ".[twain]" # also needs a TWAIN DSM (see below)
87
+ ```
88
+
89
+ Platform prerequisites:
90
+
91
+ - **Windows**: nothing extra for WIA — it and PowerShell ship with Windows. Install the
92
+ scanner's normal Windows driver so it appears in *Devices*.
93
+ - **TWAIN (optional)**: install `pytwain` (`pip install -e ".[twain]"`) and make sure a
94
+ TWAIN **DSM** is present. 64-bit Python needs `TWAINDSM.dll` (shipped by most TWAIN
95
+ 2.x drivers or the TWAIN DSM redistributable); 32-bit Python can use the classic
96
+ `twain_32.dll`. If neither is installed, the TWAIN backend just stays disabled.
97
+ - **Linux**: `sudo apt install sane-utils` (provides `scanimage`).
98
+ - **macOS**: `brew install sane-backends` for USB; network scanners work via eSCL with no extras.
99
+ - **Network scanners**: just be on the same LAN/subnet; mDNS handles discovery.
100
+
101
+ ## Connect it to Claude
102
+
103
+ ### Claude Desktop
104
+ Edit `claude_desktop_config.json` (Settings → Developer → Edit Config):
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "scanner": {
110
+ "command": "C:\\Users\\aminh\\scanner-mcp\\.venv\\Scripts\\scanner-mcp.exe"
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ On macOS/Linux use the venv path `/path/to/scanner-mcp/.venv/bin/scanner-mcp`.
117
+
118
+ ### Claude Code (CLI)
119
+ ```bash
120
+ claude mcp add scanner -- /path/to/scanner-mcp/.venv/bin/scanner-mcp
121
+ ```
122
+
123
+ Restart the client, then ask Claude: *"List my scanners"* or *"Scan the document on the
124
+ glass and read it."*
125
+
126
+ ## How you actually run it
127
+
128
+ You normally **don't launch anything yourself** — Claude Desktop/Code starts the
129
+ `scanner-mcp` server in the background (per the config above) and calls its tools when
130
+ you ask. Running `scanner-mcp` by hand just starts the MCP server, which waits silently
131
+ for JSON-RPC on stdin; it is not an interactive shell.
132
+
133
+ To test the hardware **without Claude**, use the bundled CLI, [`test_scan.py`](test_scan.py):
134
+
135
+ ```bash
136
+ # from the project folder, using the venv's Python
137
+ python test_scan.py --list # discover scanners
138
+ python test_scan.py --dpi 300 # scan (auto-selects if only one)
139
+ python test_scan.py --scanner "<id>" --source adf --format pdf
140
+ ```
141
+
142
+ ## Configuration (env vars)
143
+
144
+ | Variable | Default | Meaning |
145
+ |----------|---------|---------|
146
+ | `SCANNER_MCP_SAVE_DIR` | `~/Scans` | Where scans are written. |
147
+ | `SCANNER_MCP_LOG` | `INFO` | Log level. |
148
+
149
+ ## `scan_document` options
150
+
151
+ `scanner_id` (from `list_scanners`; auto if only one), `source`
152
+ (`auto`/`platen`/`adf`/`adf-duplex`), `resolution` (DPI), `color_mode`
153
+ (`color`/`gray`/`lineart`), `output_format` (`png`/`jpeg`/`pdf`), `save_dir`,
154
+ `return_image` (inline images for Claude to read), `ocr` (extract text).
155
+
156
+ ## Notes & limitations
157
+
158
+ - **eSCL** covers most scanners sold in the last ~decade (anything "AirPrint/AirScan"
159
+ or "Mopria" capable). Older USB-only units go through WIA/SANE instead.
160
+ - PDF output for multi-page WIA scans is assembled with Pillow.
161
+ - HTTPS eSCL devices use self-signed certs, so TLS verification is disabled for them
162
+ (typical for LAN scanners); prefer a trusted network.
163
+ - This server performs local hardware I/O only — it does not send anything to the cloud.
164
+
165
+ ## License
166
+
167
+ MIT
@@ -0,0 +1,132 @@
1
+ # scanner-mcp
2
+
3
+ ![License: MIT](https://img.shields.io/github/license/AminHA1248/scanner-mcp?color=blue)
4
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)
5
+ ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
6
+ ![MCP](https://img.shields.io/badge/MCP-server-8A2BE2)
7
+ ![Stars](https://img.shields.io/github/stars/AminHA1248/scanner-mcp?style=social)
8
+
9
+ > **Let Claude scan and read paper documents** from any USB or network scanner.
10
+
11
+ <p align="center">
12
+ <img src="https://raw.githubusercontent.com/AminHA1248/scanner-mcp/main/docs/demo.gif" alt="Ask Claude to scan a document; it calls the scan_document tool and reads the page back" width="760">
13
+ </p>
14
+
15
+ A **generic MCP server** that exposes a scanning tool to Claude (and any other MCP
16
+ client). It works with **network scanners** through the standard **eSCL / AirScan /
17
+ Mopria** protocol and with **USB scanners** through the platform driver stack —
18
+ **WIA** on Windows and **SANE** on Linux/macOS. No vendor-specific driver code.
19
+
20
+ When Claude needs to read a paper document, it can call `scan_document`, and the page
21
+ image is returned inline so Claude can read it directly (optionally OCR'd to text, or
22
+ saved as PDF).
23
+
24
+ ## What it exposes
25
+
26
+ | Tool | Purpose |
27
+ |------|---------|
28
+ | `list_scanners` | Discover every scanner reachable from this machine (network + USB). |
29
+ | `scan_document` | Scan a page/stack and return it as inline images, a saved file, and/or OCR text. |
30
+
31
+ Backends are auto-detected and degrade gracefully — the same server runs on any OS and
32
+ lights up whatever scanners it can reach:
33
+
34
+ - **eSCL** (`_uscan._tcp` mDNS) — driverless network MFPs/scanners. *Cross-platform.*
35
+ - **WIA** — USB (and some network) scanners on **Windows**, driven via PowerShell COM.
36
+ - **TWAIN** — Windows scanners that expose only a TWAIN data source (older/pro units
37
+ with no usable WIA driver). Optional; needs `pytwain` + a TWAIN DSM (see below).
38
+ - **SANE** (`scanimage`) — USB/network scanners on **Linux** and **macOS**.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ cd scanner-mcp
44
+ python -m venv .venv
45
+ # Windows: .venv\Scripts\activate
46
+ # macOS/Linux: source .venv/bin/activate
47
+ pip install -e .
48
+ # optional OCR support:
49
+ pip install -e ".[ocr]" # also needs the Tesseract binary installed
50
+ # optional TWAIN backend (Windows only):
51
+ pip install -e ".[twain]" # also needs a TWAIN DSM (see below)
52
+ ```
53
+
54
+ Platform prerequisites:
55
+
56
+ - **Windows**: nothing extra for WIA — it and PowerShell ship with Windows. Install the
57
+ scanner's normal Windows driver so it appears in *Devices*.
58
+ - **TWAIN (optional)**: install `pytwain` (`pip install -e ".[twain]"`) and make sure a
59
+ TWAIN **DSM** is present. 64-bit Python needs `TWAINDSM.dll` (shipped by most TWAIN
60
+ 2.x drivers or the TWAIN DSM redistributable); 32-bit Python can use the classic
61
+ `twain_32.dll`. If neither is installed, the TWAIN backend just stays disabled.
62
+ - **Linux**: `sudo apt install sane-utils` (provides `scanimage`).
63
+ - **macOS**: `brew install sane-backends` for USB; network scanners work via eSCL with no extras.
64
+ - **Network scanners**: just be on the same LAN/subnet; mDNS handles discovery.
65
+
66
+ ## Connect it to Claude
67
+
68
+ ### Claude Desktop
69
+ Edit `claude_desktop_config.json` (Settings → Developer → Edit Config):
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "scanner": {
75
+ "command": "C:\\Users\\aminh\\scanner-mcp\\.venv\\Scripts\\scanner-mcp.exe"
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ On macOS/Linux use the venv path `/path/to/scanner-mcp/.venv/bin/scanner-mcp`.
82
+
83
+ ### Claude Code (CLI)
84
+ ```bash
85
+ claude mcp add scanner -- /path/to/scanner-mcp/.venv/bin/scanner-mcp
86
+ ```
87
+
88
+ Restart the client, then ask Claude: *"List my scanners"* or *"Scan the document on the
89
+ glass and read it."*
90
+
91
+ ## How you actually run it
92
+
93
+ You normally **don't launch anything yourself** — Claude Desktop/Code starts the
94
+ `scanner-mcp` server in the background (per the config above) and calls its tools when
95
+ you ask. Running `scanner-mcp` by hand just starts the MCP server, which waits silently
96
+ for JSON-RPC on stdin; it is not an interactive shell.
97
+
98
+ To test the hardware **without Claude**, use the bundled CLI, [`test_scan.py`](test_scan.py):
99
+
100
+ ```bash
101
+ # from the project folder, using the venv's Python
102
+ python test_scan.py --list # discover scanners
103
+ python test_scan.py --dpi 300 # scan (auto-selects if only one)
104
+ python test_scan.py --scanner "<id>" --source adf --format pdf
105
+ ```
106
+
107
+ ## Configuration (env vars)
108
+
109
+ | Variable | Default | Meaning |
110
+ |----------|---------|---------|
111
+ | `SCANNER_MCP_SAVE_DIR` | `~/Scans` | Where scans are written. |
112
+ | `SCANNER_MCP_LOG` | `INFO` | Log level. |
113
+
114
+ ## `scan_document` options
115
+
116
+ `scanner_id` (from `list_scanners`; auto if only one), `source`
117
+ (`auto`/`platen`/`adf`/`adf-duplex`), `resolution` (DPI), `color_mode`
118
+ (`color`/`gray`/`lineart`), `output_format` (`png`/`jpeg`/`pdf`), `save_dir`,
119
+ `return_image` (inline images for Claude to read), `ocr` (extract text).
120
+
121
+ ## Notes & limitations
122
+
123
+ - **eSCL** covers most scanners sold in the last ~decade (anything "AirPrint/AirScan"
124
+ or "Mopria" capable). Older USB-only units go through WIA/SANE instead.
125
+ - PDF output for multi-page WIA scans is assembled with Pillow.
126
+ - HTTPS eSCL devices use self-signed certs, so TLS verification is disabled for them
127
+ (typical for LAN scanners); prefer a trusted network.
128
+ - This server performs local hardware I/O only — it does not send anything to the cloud.
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,89 @@
1
+ # Promotion kit
2
+
3
+ Ready-to-paste copy for getting scanner-mcp in front of people. Nothing here posts
4
+ automatically — pick what you want and I (or you) can submit it.
5
+
6
+ ---
7
+
8
+ ## 1. awesome-mcp-servers list entries
9
+
10
+ **[punkpeye/awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers)**
11
+ (icons: 🐍 Python · 🏠 local · 🪟 Windows · 🐧 Linux · 🍎 macOS). Best-fit section:
12
+ **🖥️ OS Automation**.
13
+
14
+ ```markdown
15
+ - [AminHA1248/scanner-mcp](https://github.com/AminHA1248/scanner-mcp) 🐍 🏠 🪟 🐧 🍎 - Let Claude scan and read paper documents from any USB or network scanner (eSCL/AirScan, WIA, SANE, TWAIN)
16
+ ```
17
+
18
+ **[wong2/awesome-mcp-servers](https://github.com/wong2/awesome-mcp-servers)** (plainer format):
19
+
20
+ ```markdown
21
+ - [scanner-mcp](https://github.com/AminHA1248/scanner-mcp) - Scan and read paper documents from any USB or network scanner (eSCL, WIA, SANE, TWAIN)
22
+ ```
23
+
24
+ **Directory sites that also accept submissions:** mcp.so, Glama, Smithery, PulseMCP,
25
+ mcpservers.org. Most have a "submit" form and take the same one-line description.
26
+
27
+ ---
28
+
29
+ ## 2. Show HN
30
+
31
+ **Title:**
32
+ ```
33
+ Show HN: Scanner MCP – let Claude scan and read paper documents
34
+ ```
35
+
36
+ **Body:**
37
+ ```
38
+ I wanted Claude to be able to read a physical document, so I built an MCP server
39
+ that exposes scan tools to any scanner Claude can reach.
40
+
41
+ It's generic on purpose — instead of vendor SDKs it targets the standard interfaces
42
+ each scanner category already exposes:
43
+ - eSCL / AirScan / Mopria for network scanners (driverless, cross-platform)
44
+ - WIA on Windows and SANE on Linux/macOS for USB
45
+ - TWAIN on Windows for older units with no usable WIA driver
46
+
47
+ Ask Claude "scan my document and read it" — it calls scan_document, and the page
48
+ comes back as an inline image Claude reads directly (or OCR'd, or saved as PDF).
49
+
50
+ Honest status: eSCL + WIA are tested on real hardware (a Canon TS3400); SANE and
51
+ TWAIN are written to the standard interfaces but not yet hardware-verified.
52
+
53
+ Repo (MIT): https://github.com/AminHA1248/scanner-mcp
54
+ ```
55
+
56
+ ---
57
+
58
+ ## 3. Reddit — r/ClaudeAI and r/mcp
59
+
60
+ **Title:**
61
+ ```
62
+ I built an MCP server that lets Claude scan and read paper documents
63
+ ```
64
+
65
+ **Body:**
66
+ ```
67
+ Sharing a weekend project: an MCP server that gives Claude a scan_document tool.
68
+ Put a page on your scanner, ask Claude to "scan and read it," and it reads the page
69
+ back (inline image, or OCR to text, or save a PDF).
70
+
71
+ It's driverless where it can be — eSCL/AirScan for network scanners, WIA for Windows
72
+ USB, SANE for Linux/macOS, TWAIN for older Windows scanners. One pip install, one line
73
+ in your Claude Desktop config.
74
+
75
+ Tested end-to-end on a Canon TS3400. MIT licensed, feedback welcome:
76
+ https://github.com/AminHA1248/scanner-mcp
77
+ ```
78
+
79
+ Reddit etiquette: post as "I built…", reply to comments, don't cross-post the same
80
+ text to five subs at once. r/ClaudeAI and r/mcp are the on-topic homes.
81
+
82
+ ---
83
+
84
+ ## 4. Sequencing (do this order)
85
+
86
+ 1. Publish to PyPI so every link can say `pip install scanner-mcp` (see README/workflow).
87
+ 2. Submit the awesome-mcp PRs (durable, targeted traffic).
88
+ 3. Post Show HN once, mid-morning US time; then the Reddit posts.
89
+ 4. Watch GitHub → Insights → Traffic → Referrers and lean into whatever converts.
Binary file
@@ -0,0 +1,176 @@
1
+ """Generate docs/demo.gif — a Claude-chat demo of scanner-mcp.
2
+
3
+ Shows the real way the server is used: you ask Claude, it calls the
4
+ `scan_document` tool, then reads the page back. Built from the actual scan.
5
+ Run: python docs/make_demo.py (needs Pillow; uses the real scan if present).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ from PIL import Image, ImageDraw, ImageFont
14
+
15
+ W, H = 860, 560
16
+ TITLEBAR = 34
17
+ PAD = 22
18
+ LH = 25
19
+ FONT_DIR = r"C:\Windows\Fonts"
20
+
21
+ BG = (13, 17, 23)
22
+ BAR = (22, 27, 34)
23
+ BORDER = (48, 54, 61)
24
+ GRAY = (139, 148, 158)
25
+ CYAN = (86, 182, 194)
26
+ GREEN = (86, 211, 100)
27
+ YELLOW = (210, 168, 60)
28
+ WHITE = (230, 237, 243)
29
+ USER_BUBBLE = (35, 66, 120)
30
+ PILL_BG = (26, 31, 39)
31
+
32
+ HERE = Path(__file__).resolve().parent
33
+ SCAN = Path(os.environ.get("DEMO_SCAN", r"C:\Users\aminh\Scans\scan-20260711-145133.png"))
34
+ OUT = HERE / "demo.gif"
35
+
36
+
37
+ def font(size, bold=False, mono=True):
38
+ if mono:
39
+ name = "consolab.ttf" if bold else "consola.ttf"
40
+ else:
41
+ name = "segoeuib.ttf" if bold else "segoeui.ttf"
42
+ try:
43
+ return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
44
+ except Exception:
45
+ return ImageFont.load_default()
46
+
47
+
48
+ F = font(18, mono=False)
49
+ FB = font(18, bold=True, mono=False)
50
+ FM = font(16) # mono for tool pill
51
+ FT = font(14, bold=True, mono=False)
52
+
53
+ frames: list[Image.Image] = []
54
+ durations: list[int] = []
55
+
56
+ # --- prepare the scanned page (downscaled) --------------------------------
57
+ doc_w = 214
58
+ if SCAN.exists():
59
+ doc = Image.open(SCAN).convert("RGB")
60
+ else:
61
+ doc = Image.new("RGB", (500, 700), (245, 244, 236))
62
+ doc = doc.resize((doc_w, int(doc.height * doc_w / doc.width)))
63
+
64
+ USER_TEXT = "Scan my document and read it"
65
+ bullets = [
66
+ "Upstream → Packloop → Accumulator",
67
+ "2026-06-06 20:03:02 UTC",
68
+ "KPI · Offline processing",
69
+ "Yolo26 + SAM + active",
70
+ "Wedged GPU · 1TB (userid 1000)",
71
+ "C39 > SMB > logs",
72
+ ]
73
+
74
+ # --- layout ---------------------------------------------------------------
75
+ ub_y0 = TITLEBAR + PAD
76
+ ub_h = 42
77
+ asst_y = ub_y0 + ub_h + 26
78
+ pill_y = asst_y + 22
79
+ msg_y = pill_y + 42
80
+ doc_x = PAD
81
+ doc_y = msg_y + 34
82
+ doc_h = min(doc.height, H - doc_y - PAD)
83
+ doc = doc.crop((0, 0, doc_w, doc_h))
84
+ tx = doc_x + doc_w + 34
85
+ bullets_y = doc_y + 2
86
+
87
+
88
+ def base():
89
+ img = Image.new("RGB", (W, H), BG)
90
+ d = ImageDraw.Draw(img)
91
+ d.rectangle([0, 0, W, TITLEBAR], fill=BAR)
92
+ d.line([0, TITLEBAR, W, TITLEBAR], fill=BORDER)
93
+ for i, c in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
94
+ d.ellipse([PAD + i * 22, 11, PAD + i * 22 + 12, 23], fill=c)
95
+ d.text((W // 2 - 26, 9), "Claude", font=FT, fill=GRAY)
96
+ return img, d
97
+
98
+
99
+ def user_bubble(d, visible):
100
+ full_w = d.textlength(USER_TEXT, font=F)
101
+ bw = full_w + 28
102
+ x1 = W - PAD
103
+ x0 = x1 - bw
104
+ d.rounded_rectangle([x0, ub_y0, x1, ub_y0 + ub_h], radius=12, fill=USER_BUBBLE)
105
+ d.text((x0 + 14, ub_y0 + 9), visible, font=F, fill=WHITE)
106
+
107
+
108
+ def tool_pill(d, state):
109
+ # state: None | "run" | "done"
110
+ if state is None:
111
+ return
112
+ label = "scanner ▸ scan_document"
113
+ tw = d.textlength(label, font=FM)
114
+ x0, y0 = PAD, pill_y
115
+ x1, y1 = x0 + tw + 58, y0 + 30
116
+ d.rounded_rectangle([x0, y0, x1, y1], radius=8, fill=PILL_BG, outline=BORDER)
117
+ dot = YELLOW if state == "run" else GREEN
118
+ d.ellipse([x0 + 12, y0 + 11, x0 + 20, y0 + 19], fill=dot)
119
+ d.text((x0 + 30, y0 + 6), label, font=FM, fill=GRAY)
120
+ tail = " running…" if state == "run" else " done"
121
+ d.text((x1 - 6, y0 + 6), "", font=FM)
122
+ d.text((x0 + 30 + tw + 6, y0 + 6), tail, font=FM, fill=dot)
123
+
124
+
125
+ def push(visible=USER_TEXT, pill=None, msg=False, reveal=0, nb=0, dur=90, blue_line=True):
126
+ img, d = base()
127
+ user_bubble(d, visible)
128
+ tool_pill(d, pill)
129
+ if msg:
130
+ d.text((PAD, msg_y), "Scanned 1 page — here's what's on it:", font=F, fill=WHITE)
131
+ if reveal > 0:
132
+ h = int(doc_h * min(reveal, 1.0))
133
+ img.paste(doc.crop((0, 0, doc_w, h)), (doc_x, doc_y))
134
+ if blue_line and reveal < 1.0:
135
+ ImageDraw.Draw(img).line(
136
+ [doc_x, doc_y + h, doc_x + doc_w, doc_y + h], fill=(88, 166, 255), width=2
137
+ )
138
+ if nb > 0:
139
+ d2 = ImageDraw.Draw(img)
140
+ y = bullets_y
141
+ for i in range(nb):
142
+ d2.text((tx, y), "●", font=FB, fill=GREEN)
143
+ d2.text((tx + 22, y), bullets[i], font=F, fill=WHITE)
144
+ y += LH + 8
145
+ frames.append(img)
146
+ durations.append(dur)
147
+
148
+
149
+ # 1) user types the request
150
+ for i in range(len(USER_TEXT) + 1):
151
+ push(visible=USER_TEXT[:i], dur=45)
152
+ push(dur=650)
153
+
154
+ # 2) Claude invokes the tool
155
+ push(pill="run", dur=500)
156
+ push(pill="run", dur=900)
157
+ push(pill="done", dur=600)
158
+
159
+ # 3) message + scan-line reveal of the page
160
+ push(pill="done", msg=True, dur=450)
161
+ reveal_steps = 14
162
+ for s in range(1, reveal_steps + 1):
163
+ push(pill="done", msg=True, reveal=s / reveal_steps, dur=70)
164
+
165
+ # 4) Claude's read-back bullets type in
166
+ for nb in range(len(bullets) + 1):
167
+ push(pill="done", msg=True, reveal=1.0, nb=nb, dur=90 if nb == 0 else 360)
168
+ push(pill="done", msg=True, reveal=1.0, nb=len(bullets), dur=2600)
169
+
170
+ # --- save -----------------------------------------------------------------
171
+ pal = [f.quantize(colors=128, method=Image.FASTOCTREE) for f in frames]
172
+ pal[0].save(
173
+ OUT, save_all=True, append_images=pal[1:], duration=durations,
174
+ loop=0, optimize=True, disposal=2,
175
+ )
176
+ print(f"wrote {OUT} ({len(frames)} frames, {OUT.stat().st_size/1024:.0f} KB)")